mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
- src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux. - test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix. - test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux. - src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining. - docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
/**
|
||
* @fileoverview Input that lands while a guarded submit holds the input lease is held
|
||
* and applied in arrival order once the lease is released, never refused and lost.
|
||
*
|
||
* Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and
|
||
* then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket
|
||
* route driven through a fake socket. The browser's WebSocket path does not wait for
|
||
* ACKs, so the client model below sends every frame at once and redelivers whatever
|
||
* is still unACKed afterwards, in seq order, the way `_redeliverSweep` does.
|
||
*/
|
||
import { EventEmitter } from 'node:events';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import type { FastifyInstance } from 'fastify';
|
||
import { formatPaneSnapshot } from '../src/tmux-manager.js';
|
||
import { GuardedInputBusyError, Session } from '../src/session.js';
|
||
import { registerWsRoutes } from '../src/web/routes/ws-routes.js';
|
||
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
|
||
import type { SessionPort } from '../src/web/ports/session-port.js';
|
||
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
||
|
||
type Frame = { t: 'i'; d: string; seq?: number; cid?: string };
|
||
|
||
class FakeSocket extends EventEmitter {
|
||
readyState = 1;
|
||
sent: Array<Record<string, unknown>> = [];
|
||
closedWith: number | null = null;
|
||
send(data: string): void {
|
||
this.sent.push(JSON.parse(data));
|
||
}
|
||
close(code?: number): void {
|
||
this.closedWith = code ?? 1000;
|
||
this.readyState = 3;
|
||
}
|
||
ping(): void {}
|
||
terminate(): void {
|
||
this.readyState = 3;
|
||
}
|
||
frame(msg: Frame): void {
|
||
this.emit('message', Buffer.from(JSON.stringify(msg)));
|
||
}
|
||
acks(): number[] {
|
||
return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number);
|
||
}
|
||
}
|
||
|
||
interface Harness {
|
||
session: Session;
|
||
pty: string[];
|
||
/** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */
|
||
finishEnter(ok?: boolean | Error): void;
|
||
enterDispatched(): boolean;
|
||
connect(cid: string): FakeSocket;
|
||
stop(): Promise<void>;
|
||
}
|
||
|
||
const sockets: FakeSocket[] = [];
|
||
|
||
function harness(awaitBeforeGuard = false): Harness {
|
||
const pty: string[] = [];
|
||
let finish: ((ok: boolean | Error) => void) | undefined;
|
||
let dispatched = false;
|
||
const mux = {
|
||
isAvailable: () => true,
|
||
capturePaneBuffer: (
|
||
_name: string,
|
||
_pane?: string,
|
||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||
) => {
|
||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
|
||
cols: 100,
|
||
rows: 20,
|
||
cursorX: 7,
|
||
cursorY: 0,
|
||
});
|
||
},
|
||
// Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously,
|
||
// then the `send-keys Enter` child process is awaited with the lease still held.
|
||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||
if (options?.beforeDispatch && text !== '\r') return false;
|
||
const settle = () =>
|
||
new Promise<boolean | Error>((resolve) => {
|
||
finish = resolve;
|
||
});
|
||
// A transport that awaits before its guard leaves a window in which held
|
||
// keystrokes must still invalidate the Enter.
|
||
const early = awaitBeforeGuard ? await settle() : undefined;
|
||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||
dispatched = true;
|
||
const outcome = early ?? (await settle());
|
||
if (outcome instanceof Error) throw outcome;
|
||
if (outcome) pty.push(text);
|
||
return outcome;
|
||
},
|
||
};
|
||
const session = new Session({
|
||
workingDir: '/tmp',
|
||
mode: 'codex',
|
||
mux,
|
||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
|
||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
|
||
access.ptyProcess = { write: (data: string) => pty.push(data) };
|
||
|
||
let handler: ((socket: FakeSocket, req: unknown) => void) | undefined;
|
||
const app = {
|
||
get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => {
|
||
handler = h;
|
||
},
|
||
} as unknown as FastifyInstance;
|
||
const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort;
|
||
registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] }));
|
||
|
||
return {
|
||
session,
|
||
pty,
|
||
finishEnter: (ok = true) => finish!(ok),
|
||
enterDispatched: () => dispatched,
|
||
connect: (cid: string) => {
|
||
const socket = new FakeSocket();
|
||
sockets.push(socket);
|
||
handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } });
|
||
return socket;
|
||
},
|
||
stop: async () => {
|
||
access.ptyProcess = null;
|
||
await session.stop(false);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */
|
||
function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void {
|
||
const acked = new Set(socket.acks());
|
||
for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f);
|
||
}
|
||
|
||
afterEach(() => {
|
||
for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from(''));
|
||
});
|
||
|
||
describe('input during a guarded submit lease', () => {
|
||
it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
|
||
const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' };
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
expect(h.enterDispatched()).toBe(true);
|
||
expect(h.session.guardedSubmitInFlight).toBe(true);
|
||
socket.frame(a);
|
||
// Held, not refused: no ACK yet and nothing written while the lease is held.
|
||
expect(socket.acks()).toEqual([]);
|
||
expect(h.pty).toEqual([]);
|
||
h.finishEnter(true);
|
||
await expect(submit).resolves.toBe(true);
|
||
socket.frame(b);
|
||
redeliverUnacked(socket, [a, b]);
|
||
expect(h.pty).toEqual(['\r', 'a', 'b']);
|
||
expect(socket.acks()).toEqual([1, 2]);
|
||
expect(socket.sent.filter((m) => m.dup)).toEqual([]);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('keeps arrival order for several frames during the lease and one after it', async () => {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' }));
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
for (const f of frames.slice(0, 3)) socket.frame(f);
|
||
expect(h.pty).toEqual([]);
|
||
h.finishEnter(true);
|
||
await submit;
|
||
socket.frame(frames[3]);
|
||
redeliverUnacked(socket, frames);
|
||
expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']);
|
||
expect(socket.acks()).toEqual([1, 2, 3, 4]);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('interleaves a second client by arrival, each with its own seq space', async () => {
|
||
const h = harness();
|
||
try {
|
||
const one = h.connect('c1');
|
||
const two = h.connect('c2');
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
|
||
two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' });
|
||
one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
|
||
h.finishEnter(true);
|
||
await submit;
|
||
two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' });
|
||
expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']);
|
||
expect(one.acks()).toEqual([1, 2]);
|
||
expect(two.acks()).toEqual([1, 2]);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => {
|
||
for (const outcome of [false, new Error('tmux send-keys failed')]) {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
|
||
h.finishEnter(outcome);
|
||
if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed');
|
||
else await expect(submit).resolves.toBe(false);
|
||
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
|
||
expect(h.pty).toEqual(['a', 'b']);
|
||
expect(socket.acks()).toEqual([1, 2]);
|
||
expect(h.session.guardedSubmitInFlight).toBe(false);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
}
|
||
});
|
||
|
||
it('holds untagged frames too, which have no redelivery to fall back on', async () => {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
socket.frame({ t: 'i', d: 'a' });
|
||
h.finishEnter(true);
|
||
await submit;
|
||
socket.frame({ t: 'i', d: 'b' });
|
||
expect(h.pty).toEqual(['\r', 'a', 'b']);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => {
|
||
const h = harness();
|
||
try {
|
||
const first = h.connect('c1');
|
||
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
first.frame(a);
|
||
first.close();
|
||
first.emit('close', 1006, Buffer.from(''));
|
||
h.finishEnter(true);
|
||
await submit;
|
||
expect(first.sent).toEqual([]);
|
||
const second = h.connect('c1');
|
||
second.frame(a);
|
||
second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
|
||
expect(h.pty).toEqual(['\r', 'a', 'b']);
|
||
expect(second.sent.filter((m) => m.t === 'ia')).toEqual([
|
||
{ t: 'ia', seq: 1, dup: true, last: 1 },
|
||
{ t: 'ia', seq: 2 },
|
||
]);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const cap = Session.MAX_GUARDED_HELD_INPUTS;
|
||
const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({
|
||
t: 'i',
|
||
d: `k${i}`,
|
||
seq: i + 1,
|
||
cid: 'c1',
|
||
}));
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
for (const f of frames) socket.frame(f);
|
||
// Frames past the cap are not held, not applied and not ACKed; the socket is
|
||
// closed so the browser reconnects and resends everything still unACKed.
|
||
expect(socket.closedWith).toBe(1013);
|
||
h.finishEnter(true);
|
||
await submit;
|
||
expect(h.pty.length).toBe(1 + cap);
|
||
expect(socket.acks()).toEqual([]);
|
||
socket.emit('close', 1013, Buffer.from(''));
|
||
const again = h.connect('c1');
|
||
for (const f of frames) again.frame(f);
|
||
expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]);
|
||
expect(again.acks()).toEqual(frames.map((f) => f.seq));
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => {
|
||
const h = harness();
|
||
try {
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
const order: string[] = [];
|
||
const key = h.session.performAuxiliaryInput(async () => {
|
||
order.push('key');
|
||
});
|
||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||
expect(order).toEqual([]);
|
||
for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) {
|
||
expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held');
|
||
}
|
||
expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full');
|
||
await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf(
|
||
GuardedInputBusyError
|
||
);
|
||
await expect(h.session.writeViaMux('lost')).resolves.toBe(false);
|
||
h.finishEnter(true);
|
||
await submit;
|
||
await key;
|
||
expect(order[0]).toBe('key');
|
||
expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1);
|
||
expect(order).not.toContain('lost');
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('caps held bytes as well as held frames', async () => {
|
||
const h = harness();
|
||
try {
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held');
|
||
expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full');
|
||
h.finishEnter(true);
|
||
await submit;
|
||
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free');
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('a frame arriving while a held named key is still being sent waits for it', async () => {
|
||
const h = harness();
|
||
try {
|
||
const socket = h.connect('c1');
|
||
let keySent!: () => void;
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
const key = h.session.performAuxiliaryInput(async () => {
|
||
await new Promise<void>((resolve) => {
|
||
keySent = resolve;
|
||
});
|
||
h.pty.push('<newline>');
|
||
});
|
||
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
|
||
h.finishEnter(true);
|
||
await submit;
|
||
// The lease is over, but the held key is still in flight: input stays held.
|
||
expect(h.session.guardedSubmitInFlight).toBe(false);
|
||
expect(h.session.guardedInputHeld).toBe(true);
|
||
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
|
||
expect(h.pty).toEqual(['\r']);
|
||
keySent();
|
||
await key;
|
||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||
expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']);
|
||
expect(socket.acks()).toEqual([1, 2]);
|
||
expect(h.session.guardedInputHeld).toBe(false);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => {
|
||
const h = harness();
|
||
const route = await createRouteTestHarness(registerSessionRoutes);
|
||
try {
|
||
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
expect(h.session.guardedSubmitInFlight).toBe(true);
|
||
// The Stop button's Ctrl+C: an untagged POST whose answer nobody reads.
|
||
const res = await route.app.inject({
|
||
method: 'POST',
|
||
url: `/api/sessions/${h.session.id}/input`,
|
||
payload: { input: '\x03' },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
expect(h.pty).toEqual([]);
|
||
h.finishEnter(true);
|
||
await expect(submit).resolves.toBe(true);
|
||
expect(h.pty).toEqual(['\r', '\x03']);
|
||
} finally {
|
||
await route.app.close();
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('a held untagged raw send-and-wait reports the held write as delivered', async () => {
|
||
const h = harness();
|
||
const route = await createRouteTestHarness(registerSessionRoutes);
|
||
try {
|
||
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
const answer = route.app.inject({
|
||
method: 'POST',
|
||
url: `/api/sessions/${h.session.id}/input`,
|
||
payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 },
|
||
});
|
||
// Private, but the only proof the route reached the hold before the lease ends.
|
||
const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld;
|
||
await vi.waitFor(() => expect(queue).toHaveLength(1));
|
||
expect(h.pty).toEqual([]);
|
||
h.finishEnter(true);
|
||
await submit;
|
||
const res = await answer;
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json().data).toMatchObject({ delivered: true, duplicate: false });
|
||
expect(h.pty).toEqual(['\r', '\x03']);
|
||
} finally {
|
||
await route.app.close();
|
||
await h.stop();
|
||
}
|
||
});
|
||
|
||
it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => {
|
||
const h = harness(true);
|
||
try {
|
||
const socket = h.connect('c1');
|
||
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
|
||
await Promise.resolve();
|
||
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
|
||
h.finishEnter(true);
|
||
await expect(submit).resolves.toBe(false);
|
||
expect(h.enterDispatched()).toBe(false);
|
||
expect(h.pty).toEqual(['a']);
|
||
expect(socket.acks()).toEqual([1]);
|
||
} finally {
|
||
await h.stop();
|
||
}
|
||
});
|
||
});
|