Merge pull request #226 from christianhaberl/fix/input-loss-on-failed-delivery

fix(api,ws): an input whose delivery fails can be retried instead of being lost
This commit is contained in:
Ark0N
2026-08-08 01:31:10 +02:00
committed by GitHub
7 changed files with 303 additions and 19 deletions
+22 -9
View File
@@ -879,28 +879,41 @@ export function registerSessionRoutes(
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
// client's ACK, so a tagged duplicate redelivery must still return 200 but
// skip the write. Untagged requests (curl/legacy) always apply.
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
const tagged = typeof clientId === 'string' && typeof seq === 'number';
if (tagged && !session.shouldApplyInput(clientId as string, seq as number)) {
return {};
}
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useMux) {
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
// Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure.
//
// Because the response has already been sent by then, a failure here is the
// one case the caller can never learn about — so the dedup bookkeeping is
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
// very mechanism reliable delivery exists for, is rejected as a duplicate.
const undoOnFailure = () => {
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
};
session
.writeViaMux(inputStr)
.then((ok) => {
if (!ok) {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
session.write(inputStr);
}
if (ok) return;
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
if (!session.write(inputStr)) undoOnFailure();
})
.catch(() => {
session.write(inputStr);
if (!session.write(inputStr)) undoOnFailure();
});
} else {
session.write(inputStr);
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
if (!session.write(inputStr) && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
}
return {};
});
+8 -2
View File
@@ -180,13 +180,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
delivered = session.write(msg.d);
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (delivered && seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (