refactor: correctly close session on send failure

This commit is contained in:
Nicolas Meienberger 2026-04-07 18:13:48 +02:00
parent ad50ec9392
commit 9fba2d083c
3 changed files with 53 additions and 8 deletions

View file

@ -3,6 +3,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"module": "index.ts", "module": "index.ts",
"scripts": {
"tsc": "tsc --noEmit"
},
"dependencies": { "dependencies": {
"@zerobyte/contracts": "workspace:*", "@zerobyte/contracts": "workspace:*",
"@zerobyte/core": "workspace:*", "@zerobyte/core": "workspace:*",

View file

@ -69,3 +69,25 @@ test("emits backup.failed when a backup command hits a restic error", async () =
session.close(); session.close();
} }
}); });
test("closes the websocket when an outbound send throws", async () => {
const close = mock(() => undefined);
const session = createControllerSession(
fromPartial({
send: () => {
throw new Error("socket write failed");
},
close,
}),
);
try {
session.onOpen();
await waitForExpect(() => {
expect(close).toHaveBeenCalledTimes(1);
});
} finally {
session.close();
}
});

View file

@ -17,6 +17,7 @@ export type ControllerSession = {
}; };
export const createControllerSession = (ws: WebSocket): ControllerSession => { export const createControllerSession = (ws: WebSocket): ControllerSession => {
let isClosed = false;
const outboundQueue = Effect.runSync(Queue.bounded<AgentWireMessage>(64)); const outboundQueue = Effect.runSync(Queue.bounded<AgentWireMessage>(64));
const inboundQueue = Effect.runSync(Queue.bounded<ControllerWireMessage>(64)); const inboundQueue = Effect.runSync(Queue.bounded<ControllerWireMessage>(64));
const runningJobsRef = Effect.runSync(Ref.make<Map<string, RunningJob>>(new Map())); const runningJobsRef = Effect.runSync(Ref.make<Map<string, RunningJob>>(new Map()));
@ -63,6 +64,31 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => {
offerOutbound, offerOutbound,
}; };
const closeSession = () => {
if (isClosed) {
return;
}
isClosed = true;
void Effect.runPromise(abortRunningJobs).catch(() => {});
void Effect.runPromise(Fiber.interrupt(writerFiber)).catch(() => {});
void Effect.runPromise(Fiber.interrupt(processorFiber)).catch(() => {});
void Effect.runPromise(Queue.shutdown(outboundQueue)).catch(() => {});
void Effect.runPromise(Queue.shutdown(inboundQueue)).catch(() => {});
};
const handleSendFailure = (reason: string) => {
logger.error(`Closing agent session after an outbound websocket send failed: ${reason}`);
try {
ws.close();
} catch (error) {
logger.error(`Failed to close controller websocket after send failure: ${toMessage(error)}`);
}
closeSession();
};
const writerFiber = Effect.runFork( const writerFiber = Effect.runFork(
Effect.forever( Effect.forever(
Effect.gen(function* () { Effect.gen(function* () {
@ -71,7 +97,7 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => {
try { try {
ws.send(message); ws.send(message);
} catch (error) { } catch (error) {
logger.error(`Failed to send controller message: ${toMessage(error)}`); handleSendFailure(toMessage(error));
} }
}); });
}), }),
@ -115,12 +141,6 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => {
logger.error(`Failed to queue inbound message: ${toMessage(error)}`); logger.error(`Failed to queue inbound message: ${toMessage(error)}`);
}); });
}, },
close: () => { close: closeSession,
void Effect.runPromise(abortRunningJobs).catch(() => {});
void Effect.runPromise(Fiber.interrupt(writerFiber)).catch(() => {});
void Effect.runPromise(Fiber.interrupt(processorFiber)).catch(() => {});
void Effect.runPromise(Queue.shutdown(outboundQueue)).catch(() => {});
void Effect.runPromise(Queue.shutdown(inboundQueue)).catch(() => {});
},
}; };
}; };