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
+15 -2
View File
@@ -30,13 +30,22 @@ export class MockSession extends EventEmitter {
this._muxName = `codeman-test-${id.slice(0, 8)}`;
}
/** Direct PTY write (used by session.write()) */
write(data: string): void {
/**
* Set to simulate a session whose PTY is gone: both write paths report failure,
* which is the state in which input used to disappear silently.
*/
failWrites = false;
/** Direct PTY write (used by session.write()). Mirrors the real boolean return. */
write(data: string): boolean {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
}
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
}
@@ -44,6 +53,10 @@ export class MockSession extends EventEmitter {
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
* exercising the reliable-delivery path behave like production. */
private _appliedInputSeq = new Map<string, number>();
forgetInputSeq(clientId: string, seq: number): void {
if (this._appliedInputSeq.get(clientId) === seq) this._appliedInputSeq.set(clientId, seq - 1);
}
shouldApplyInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
if (last !== undefined && seq <= last) return false;
+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);
});
});
+49
View File
@@ -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 {