Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: abort transmission after response timeout, do not retry, do not restart controller unless callback is missing #6408

Merged
merged 3 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/api/driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,10 +684,10 @@ interface ZWaveOptions extends ZWaveHostOptions {
byte: number; // >=1, default: 150 ms

/**
* How long to wait for a controller response. Usually this timeout should never elapse,
* so this is merely a safeguard against the driver stalling.
* How long to wait for a controller response. Usually this should never elapse, but when it does,
* the driver will abort the transmission and try to recover the controller if it is unresponsive.
*/
response: number; // [500...60000], default: 30000 ms
response: number; // [500...60000], default: 10000 ms

/** How long to wait for a callback from the host for a SendData[Multicast]Request */
sendDataCallback: number; // >=10000, default: 65000 ms
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/error/ZWaveError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ export function isMissingControllerACK(
&& e.context === "ACK";
}

export function isMissingControllerResponse(
e: unknown,
): e is ZWaveError & {
code: ZWaveErrorCodes.Controller_Timeout;
context: "response";
} {
return isZWaveError(e)
&& e.code === ZWaveErrorCodes.Controller_Timeout
&& e.context === "response";
}

export function isMissingControllerCallback(
e: unknown,
): e is ZWaveError & {
Expand Down
62 changes: 45 additions & 17 deletions packages/zwave-js/src/lib/driver/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
highResTimestamp,
isMissingControllerACK,
isMissingControllerCallback,
isMissingControllerResponse,
isZWaveError,
messageRecordToLines,
securityClassIsS2,
Expand Down Expand Up @@ -250,8 +251,8 @@ const defaultOptions: ZWaveOptions = {
ack: 1000,
byte: 150,
// Ideally we'd want to have this as low as possible, but some
// 500 series controllers can take upwards of 10 seconds to respond sometimes.
response: 30000,
// 500 series controllers can take several seconds to respond sometimes.
response: 10000,
report: 1000, // ReportTime timeout SHOULD be set to CommandTime + 1 second
nonce: 5000,
sendDataCallback: 65000, // as defined in INS13954
Expand Down Expand Up @@ -3556,17 +3557,21 @@ export class Driver extends TypedEventEmitter<DriverEventCallbacks>
* Handles the case that the controller didn't send the callback for a SendData in time
* Returns `true` if the transaction failure was handled, `false` if it needs to be rejected.
*/
private handleMissingSendDataCallback(
private handleMissingSendDataResponseOrCallback(
transaction: Transaction,
error: ZWaveError & {
code: ZWaveErrorCodes.Controller_Timeout;
context: "callback";
context: "callback" | "response";
},
): boolean {
if (!this._controller) return false;

if (
this._recoveryPhase
// The SendData response can time out on older controllers trying to reach a dead node.
// In this case, we do not want to reset the controller, but just mark the node as dead.
error.context === "response"
// Also do this if the callback is timing out even after restarting the controller
|| this._recoveryPhase
=== ControllerRecoveryPhase.CallbackTimeoutAfterReset
) {
const node = this.getNodeUnsafe(transaction.message);
Expand Down Expand Up @@ -4849,7 +4854,7 @@ ${handlers.length} left`,
msg,
// Ignore the number of attempts while jammed
attemptNumber - jammedAttempts,
e.code,
e,
)
) {
// Retry the command
Expand Down Expand Up @@ -4909,19 +4914,29 @@ ${handlers.length} left`,
private mayRetrySerialAPICommand(
msg: Message,
attemptNumber: number,
errorCode: ZWaveErrorCodes,
error: ZWaveError,
): boolean {
// Only retry Send Data, nothing else
if (!isSendData(msg)) return false;

// Don't try to resend SendData commands when the response times out
if (
msg instanceof SendDataMulticastRequest
|| msg instanceof SendDataMulticastBridgeRequest
error.code === ZWaveErrorCodes.Controller_Timeout
&& error.context === "response"
) {
// Don't try to resend multicast messages if they were already transmitted.
// One or more nodes might have already reacted
if (errorCode === ZWaveErrorCodes.Controller_CallbackNOK) {
return false;
}
return false;
}

// Don't try to resend multicast messages if they were already transmitted.
// One or more nodes might have already reacted
if (
(msg instanceof SendDataMulticastRequest
|| msg instanceof SendDataMulticastBridgeRequest)
&& error.code === ZWaveErrorCodes.Controller_CallbackNOK
) {
return false;
}

return attemptNumber < msg.maxSendAttempts;
}

Expand All @@ -4936,7 +4951,8 @@ ${handlers.length} left`,
const machine = createSerialAPICommandMachine(
msg,
{
sendData: this.writeSerial.bind(this),
sendData: (data) => this.writeSerial(data),
sendDataAbort: () => this.abortSendData(false),
notifyUnsolicited: (msg) => {
void this.handleUnsolicitedMessage(msg);
},
Expand Down Expand Up @@ -5024,6 +5040,15 @@ ${handlers.length} left`,
}
});

// Uncomment this for debugging state machine transitions
// this.serialAPIInterpreter.onTransition((state) => {
// if (state.changed) {
// this.driverLog.print(
// `CMDMACHINE: ${JSON.stringify(state.toStrings())}`,
// );
// }
// });

this.serialAPIInterpreter.start();

return result;
Expand Down Expand Up @@ -5663,9 +5688,12 @@ ${handlers.length} left`,
if (this.handleMissingControllerACK(transaction, error)) return;
} else if (
isSendData(transaction.message)
&& isMissingControllerCallback(error)
&& (isMissingControllerResponse(error)
|| isMissingControllerCallback(error))
) {
if (this.handleMissingSendDataCallback(transaction, error)) return;
if (
this.handleMissingSendDataResponseOrCallback(transaction, error)
) return;
}

this.rejectTransaction(transaction, error);
Expand Down
41 changes: 32 additions & 9 deletions packages/zwave-js/src/lib/driver/SerialAPICommandMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ interface TestMachineStateSchema {
waitForACK: {};
waitForResponse: {};
waitForCallback: {};
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// waitForCallbackAfterTimeout: {};
unsolicited: {};
success: {};
failure: {};
Expand Down Expand Up @@ -227,7 +229,11 @@ const testMachine = Machine<
{ target: "failure" },
],
RESPONSE_TIMEOUT: [
{ target: "sending", cond: "maySendAgain" },
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// {
// target: "waitForCallbackAfterTimeout",
// cond: "expectsCallback",
// },
{ target: "failure" },
],
UNSOLICITED: "unsolicited",
Expand All @@ -247,6 +253,13 @@ const testMachine = Machine<
UNSOLICITED: "unsolicited",
},
},
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// waitForCallbackAfterTimeout: {
// on: {
// CALLBACK_NOK: "failure",
// CALLBACK_TIMEOUT: "failure",
// },
// },
unsolicited: {
meta: {
test: ({
Expand Down Expand Up @@ -386,13 +399,22 @@ testPlans.forEach((plan) => {
);

plan.paths.forEach((path) => {
// Uncomment this and change the path description to only run a single test
// if (
// !path.description.includes(
// `CREATE ({"resp":true,"cb":true}) → SEND_FAILURE → SEND_FAILURE → SEND_SUCCESS → ACK → RESPONSE_TIMEOUT → CALLBACK_NOK`,
// )
// ) {
// return;
// }
test.serial(`${planDescription} ${path.description}`, async (t) => {
// eslint-disable-next-line prefer-const
let context: TestContext;
const sendData = sinon.stub().callsFake(() => {
context.sendDataPromise = createDeferredPromise();
return context.sendDataPromise;
});
const sendDataAbort = sinon.stub();
const notifyRetry = sinon.stub();
const timestamp = () => 0;
const logOutgoingMessage = () => {};
Expand All @@ -402,6 +424,7 @@ testPlans.forEach((plan) => {

const implementations: SerialAPICommandServiceImplementations = {
sendData,
sendDataAbort,
notifyRetry,
timestamp,
logOutgoingMessage,
Expand Down Expand Up @@ -483,11 +506,11 @@ testPlans.forEach((plan) => {
});
});

test.serial("coverage", (t) => {
testModel.testCoverage({
filter: (stateNode) => {
return !!stateNode.meta;
},
});
t.pass();
});
// test.serial("coverage", (t) => {
// testModel.testCoverage({
// filter: (stateNode) => {
// return !!stateNode.meta;
// },
// });
// t.pass();
// });
53 changes: 45 additions & 8 deletions packages/zwave-js/src/lib/driver/SerialAPICommandMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createMachine,
raise,
} from "xstate";
import { isSendData } from "../serialapi/transport/SendDataShared";
import type { ZWaveOptions } from "./ZWaveOptions";

/* eslint-disable @typescript-eslint/ban-types */
Expand Down Expand Up @@ -93,6 +94,7 @@ export type SerialAPICommandDoneData =
export interface SerialAPICommandServiceImplementations {
timestamp: () => number;
sendData: (data: Buffer) => Promise<void>;
sendDataAbort: () => Promise<void>;
notifyRetry: (
lastError: SerialAPICommandError | undefined,
message: Message,
Expand Down Expand Up @@ -148,9 +150,13 @@ export function getSerialAPICommandMachineConfig(
timestamp,
logOutgoingMessage,
notifyUnsolicited,
sendDataAbort,
}: Pick<
SerialAPICommandServiceImplementations,
"timestamp" | "logOutgoingMessage" | "notifyUnsolicited"
| "timestamp"
| "logOutgoingMessage"
| "notifyUnsolicited"
| "sendDataAbort"
>,
attemptsConfig: SerialAPICommandMachineParams["attempts"],
): SerialAPICommandMachineConfig {
Expand Down Expand Up @@ -255,22 +261,42 @@ export function getSerialAPICommandMachineConfig(
],
},
after: {
RESPONSE_TIMEOUT: {
target: "retry",
actions: assign({
lastError: (_) => "response timeout",
}),
},
// Do not retry when a response times out
RESPONSE_TIMEOUT: [
{
cond: "isSendData",
target: "waitForCallback",
actions: [
() => sendDataAbort(),
assign({
lastError: (_) => "response timeout",
}),
],
},
{
target: "failure",
actions: assign({
lastError: (_) => "response timeout",
}),
},
],
},
},
waitForCallback: {
always: [{ target: "success", cond: "expectsNoCallback" }],
on: {
callback: [
{
// Preserve "response timeout" errors
// A NOK callback afterwards is expected, but we're not interested in it
target: "failure",
cond: "callbackIsNOKAfterTimedOutResponse",
},
{
target: "failure",
cond: "callbackIsNOK",
actions: assign({
// Preserve "response timeout" errors
lastError: (_) => "callback NOK",
result: (_, evt) => (evt as any).message,
}),
Expand Down Expand Up @@ -342,7 +368,10 @@ export function getSerialAPICommandMachineOptions(
{
sendData,
notifyRetry,
}: Pick<SerialAPICommandServiceImplementations, "sendData" | "notifyRetry">,
}: Pick<
SerialAPICommandServiceImplementations,
"sendData" | "sendDataAbort" | "notifyRetry"
>,
timeoutConfig: SerialAPICommandMachineParams["timeouts"],
): SerialAPICommandMachineOptions {
return {
Expand All @@ -365,6 +394,7 @@ export function getSerialAPICommandMachineOptions(
},
guards: {
mayRetry: (ctx) => ctx.attempts < ctx.maxAttempts,
isSendData: (ctx) => isSendData(ctx.msg),
expectsNoResponse: (ctx) => !ctx.msg.expectsResponse(),
expectsNoCallback: (ctx) => !ctx.msg.expectsCallback(),
isExpectedMessage: (ctx, evt, meta) =>
Expand All @@ -378,6 +408,13 @@ export function getSerialAPICommandMachineOptions(
// assume responses without success indication to be OK
&& isSuccessIndicator(evt.message)
&& !evt.message.isOK(),
callbackIsNOKAfterTimedOutResponse: (ctx, evt) =>
evt.type === "callback"
// assume callbacks without success indication to be OK
&& isSuccessIndicator(evt.message)
&& !evt.message.isOK()
&& isSendData(ctx.msg)
&& ctx.lastError === "response timeout",
callbackIsNOK: (ctx, evt) =>
evt.type === "callback"
// assume callbacks without success indication to be OK
Expand Down
6 changes: 3 additions & 3 deletions packages/zwave-js/src/lib/driver/ZWaveOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export interface ZWaveOptions extends ZWaveHostOptions {
byte: number; // >=1, default: 150 ms

/**
* How long to wait for a controller response. Usually this timeout should never elapse,
* so this is merely a safeguard against the driver stalling.
* How long to wait for a controller response. Usually this should never elapse, but when it does,
* the driver will abort the transmission and try to recover the controller if it is unresponsive.
*/
response: number; // [500...60000], default: 30000 ms
response: number; // [500...60000], default: 10000 ms

/** How long to wait for a callback from the host for a SendData[Multicast]Request */
sendDataCallback: number; // >=10000, default: 65000 ms
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ integrationTest(
}),
);

await wait(100);
await wait(200);

const sensorValue = MultilevelSensorCCValues.value(
"Air temperature",
Expand Down
Loading