2026-10-26 09:05:25 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* @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';
|
2026-10-27 02:07:39 +01:00
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
2026-10-26 09:05:25 -04:00
|
|
|
|
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';
|
2026-10-27 02:07:39 +01:00
|
|
|
|
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
|
2026-10-26 09:05:25 -04:00
|
|
|
|
import type { SessionPort } from '../src/web/ports/session-port.js';
|
2026-10-27 02:07:39 +01:00
|
|
|
|
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
2026-10-26 09:05:25 -04:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-27 02:07:39 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-26 09:05:25 -04:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|