diff --git a/.changeset/input-delivery-retryable.md b/.changeset/input-delivery-retryable.md index c9ec5357..596c41e9 100644 --- a/.changeset/input-delivery-retryable.md +++ b/.changeset/input-delivery-retryable.md @@ -14,8 +14,11 @@ been delivered. The bookkeeping is now rolled back on failure and the WebSocket ACK withheld, so the client redelivers. `Session.write()` reports whether it reached a PTY at all -instead of silently swallowing the data, and the non-mux POST branch — whose -response has not gone out yet — answers `OPERATION_FAILED` rather than a cheerful 200. +instead of silently swallowing the data. + +Response codes are unchanged: a session can legitimately have no PTY yet (created +but not started), so turning that into a failure status would be a contract change +of its own. Note this does not remove the root cause: the POST still answers 200 before the mux write is attempted, so a client that treats any 2xx as final still cannot diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts index 5b722036..76efc37d 100644 --- a/test/routes/ws-routes.test.ts +++ b/test/routes/ws-routes.test.ts @@ -208,6 +208,55 @@ describe('ws-routes', () => { } }); + it('ACKs a delivered input and burns its seq', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'i', d: 'ok\r', cid: 'c1', seq: 1 })); + + expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 1 }); + expect(session.shouldApplyInput('c1', 1)).toBe(false); + } finally { + ws.close(); + } + }); + + it('withholds the ACK and re-opens the seq when the write did not land', async () => { + // A session whose PTY is gone swallows the write. ACKing anyway told the + // client to drop the frame from its durable queue while the seq stayed + // burnt, so the retry that reliable delivery exists for was rejected as a + // duplicate — the input was lost for good. + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + session.failWrites = true; + + ws.send(JSON.stringify({ t: 'i', d: 'lost\r', cid: 'c1', seq: 1 })); + + await expect(nextMessage(ws, 600)).rejects.toThrow(/timeout/); + expect(session.shouldApplyInput('c1', 1)).toBe(true); + } finally { + ws.close(); + } + }); + + it('still ACKs a duplicate frame the server deliberately skipped', async () => { + // Dedup must stay silent-but-acknowledged: the client has to be able to + // drop a frame it already delivered once. + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + session.shouldApplyInput('c1', 7); // pretend seq 7 already landed + + ws.send(JSON.stringify({ t: 'i', d: 'again\r', cid: 'c1', seq: 7 })); + + expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 7 }); + expect(session.writeBuffer).not.toContain('again\r'); + } finally { + ws.close(); + } + }); + it('ignores input exceeding MAX_INPUT_LENGTH', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try {