fix(api,ws): an input whose delivery fails can be retried instead of being lost

Both input paths recorded the (clientId, seq) pair as applied and acknowledged the
frame BEFORE knowing whether the write had landed: the POST route because its mux
write is fire-and-forget so the response never waits on a tmux child, the
WebSocket handler because it ACKed unconditionally.

When the write then failed, the client dropped the frame from its durable queue
and the server rejected the retry as a duplicate. The reliable-delivery layer was
guaranteeing exactly-once delivery of something that had never been delivered —
and `Session.write()` returned void, so a session whose PTY was gone swallowed the
data with no signal at all.

- `forgetInputSeq()` rolls the bookkeeping back on failure, but only when that seq
  is still the newest one; a later input has superseded it and must not re-open.
- The WebSocket handler withholds its ACK when the write did not land, so the
  client redelivers.
- `Session.write()` reports whether it reached a PTY.

Response codes are unchanged, deliberately: a session can legitimately have no PTY
yet, and turning that into a failure status would be a contract change of its own.

What this does NOT do: remove the root cause. The POST still answers 200 before
the mux write is attempted, so a client that treats any 2xx as final cannot learn
about that failure. What closes is the narrower window — the write failed AND the
ACK never reached the client — plus the whole WebSocket path. Closing the rest
would mean awaiting the tmux child inside the request.

9 tests. They drive the HTTP route, not only the Session primitives: with the
rollback removed from the route, 2 of them fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudia
2026-08-07 01:36:33 +02:00
parent d41f28bc14
commit ebfcac6ad1
6 changed files with 251 additions and 16 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview A lost input must stay retryable.
*
* POST /api/sessions/:id/input answers 200 BEFORE the write is attempted — the mux
* write is fire-and-forget so the HTTP response never waits on a tmux child. The
* dedup bookkeeping, however, recorded the (clientId, seq) pair as applied at that
* same moment. A write that then failed left the client with a 200, no message in
* the pane, and a seq the server would reject as a duplicate on retry: the input was
* unrecoverable by the very mechanism meant to make delivery reliable.
*
* Observed in the wild: a prompt shown as sent in a chat client, a 200 in the proxy
* log, and an empty prompt line in the pane.
*/
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Session } from '../../src/session.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
async function createEnvelopeHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
type Internals = { _appliedInputSeq: Map<string, number> };
const seqOf = (s: Session, client: string) => (s as unknown as Internals)._appliedInputSeq.get(client);
describe('input dedup bookkeeping', () => {
const make = () => new Session({ workingDir: '/tmp', mode: 'claude' });
it('accepts an increasing seq once and rejects the replay', () => {
const s = make();
expect(s.shouldApplyInput('c1', 1)).toBe(true);
expect(s.shouldApplyInput('c1', 1)).toBe(false);
expect(s.shouldApplyInput('c1', 2)).toBe(true);
});
it('forgetInputSeq re-opens a failed delivery for retry', () => {
const s = make();
expect(s.shouldApplyInput('c1', 7)).toBe(true);
s.forgetInputSeq('c1', 7); // the write failed after the 200 went out
expect(s.shouldApplyInput('c1', 7)).toBe(true);
});
it('does not re-open a seq that a later input has superseded', () => {
// Rolling back blindly would let an old, already-superseded message replay.
const s = make();
s.shouldApplyInput('c1', 7);
s.shouldApplyInput('c1', 8);
s.forgetInputSeq('c1', 7);
expect(s.shouldApplyInput('c1', 8)).toBe(false);
expect(seqOf(s, 'c1')).toBe(8);
});
it('is scoped per client', () => {
const s = make();
s.shouldApplyInput('c1', 5);
s.forgetInputSeq('c2', 5);
expect(s.shouldApplyInput('c1', 5)).toBe(false);
});
it('tolerates a rollback for a client that was never seen', () => {
const s = make();
expect(() => s.forgetInputSeq('ghost', 3)).not.toThrow();
});
});
describe('Session.write delivery signal', () => {
it('reports false when there is no PTY instead of swallowing the data', () => {
// The silent swallow was the third way input could vanish: no PTY, no error,
// no return value — the caller had no way to know.
const s = new Session({ workingDir: '/tmp', mode: 'claude' });
expect(s.write('hello\r')).toBe(false);
});
});
/**
* Wiring, not primitives.
*
* The first version of this file tested Session directly and nothing else: reverting
* the route to master — deleting the rollback call, the load-bearing half of the fix —
* left all six tests green. These drive the actual HTTP route.
*/
describe('POST /api/sessions/:id/input rollback wiring', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (body: Record<string, unknown>, id = 'test-session-1') =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/input`, payload: body });
it('rolls the seq back when both the mux write and the direct write fail', async () => {
const session = harness.ctx.sessions.get('test-session-1')!;
session.failWrites = true; // writeViaMux false AND write() false
await post({ input: 'lost\r', useMux: true, clientId: 'c1', seq: 1 });
await new Promise((r) => setTimeout(r, 20)); // the mux write is fire-and-forget
// The retry the client would make must be accepted, not swallowed as a duplicate.
expect(session.shouldApplyInput('c1', 1)).toBe(true);
});
it('keeps the seq burnt when delivery succeeded', async () => {
const session = harness.ctx.sessions.get('test-session-1')!;
await post({ input: 'fine\r', useMux: true, clientId: 'c1', seq: 1 });
await new Promise((r) => setTimeout(r, 20));
expect(session.shouldApplyInput('c1', 1)).toBe(false);
});
it('rolls the seq back on the non-mux path too', async () => {
// Still a 200: a session may legitimately have no PTY yet, and turning that
// into a failure status would be a contract change. Re-opening the seq is not.
const session = harness.ctx.sessions.get('test-session-1')!;
session.failWrites = true;
const res = await post({ input: 'x\r', useMux: false, clientId: 'c2', seq: 5 });
expect(res.statusCode).toBe(200);
expect(session.shouldApplyInput('c2', 5)).toBe(true);
});
});