plugin: welcome-timeout + stall watchdog (bulletproof review: fixes handshake hang + wedged-socket unbounded buffering); smoke 27 checks, e2e 13/13

This commit is contained in:
Raphael Westphal
2026-08-18 15:31:42 +02:00
parent 49840225e2
commit 1a267bf489
3 changed files with 1377 additions and 925 deletions
+723 -510
View File
File diff suppressed because it is too large Load Diff
+170 -149
View File
@@ -12,171 +12,192 @@ import type { Duplex } from "node:stream";
export const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; export const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
export interface ClientFrame { export interface ClientFrame {
v: number; v: number;
sessionId: string; sessionId: string;
seq: number; seq: number;
ts: number; ts: number;
type: string; type: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface MiniDaemon { export interface MiniDaemon {
url: string; url: string;
frames: ClientFrame[]; frames: ClientFrame[];
authHeaders: string[]; authHeaders: string[];
connections(): number; connections(): number;
pushAll(text: string): void; pushAll(text: string): void;
dropConnections(): void; dropConnections(): void;
close(): void; /** Pause reads on all live sockets: simulate a daemon wedged without TCP close. */
wedge(): void;
unwedge(): void;
close(): void;
} }
export function check(name: string, ok: boolean, detail = ""): void { export function check(name: string, ok: boolean, detail = ""): void {
if (ok) console.log(`ok ${name}`); if (ok) console.log(`ok ${name}`);
else console.log(`FAIL ${name} ${detail}`); else console.log(`FAIL ${name} ${detail}`);
} }
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
export async function waitFor(cond: () => boolean, timeoutMs: number, stepMs = 25): Promise<boolean> { export async function waitFor(
const deadline = Date.now() + timeoutMs; cond: () => boolean,
while (Date.now() < deadline) { timeoutMs: number,
if (cond()) return true; stepMs = 25,
await sleep(stepMs); ): Promise<boolean> {
} const deadline = Date.now() + timeoutMs;
return cond(); while (Date.now() < deadline) {
if (cond()) return true;
await sleep(stepMs);
}
return cond();
} }
function encodeTextFrame(text: string): Buffer { function encodeTextFrame(text: string): Buffer {
const payload = Buffer.from(text, "utf8"); const payload = Buffer.from(text, "utf8");
const len = payload.length; const len = payload.length;
let header: Buffer; let header: Buffer;
if (len < 126) header = Buffer.from([0x81, len]); if (len < 126) header = Buffer.from([0x81, len]);
else if (len < 65536) { else if (len < 65536) {
header = Buffer.alloc(4); header = Buffer.alloc(4);
header[0] = 0x81; header[0] = 0x81;
header[1] = 126; header[1] = 126;
header.writeUInt16BE(len, 2); header.writeUInt16BE(len, 2);
} else { } else {
header = Buffer.alloc(10); header = Buffer.alloc(10);
header[0] = 0x81; header[0] = 0x81;
header[1] = 127; header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2); header.writeBigUInt64BE(BigInt(len), 2);
} }
return Buffer.concat([header, payload]); return Buffer.concat([header, payload]);
} }
function decodeFrames(chunk: Buffer): { frames: Array<{ opcode: number; data: Buffer }>; consumed: number } { function decodeFrames(chunk: Buffer): {
const frames: Array<{ opcode: number; data: Buffer }> = []; frames: Array<{ opcode: number; data: Buffer }>;
let offset = 0; consumed: number;
while (offset + 2 <= chunk.length) { } {
const opcode = chunk[offset] & 0x0f; const frames: Array<{ opcode: number; data: Buffer }> = [];
const masked = (chunk[offset + 1] & 0x80) !== 0; let offset = 0;
let len = chunk[offset + 1] & 0x7f; while (offset + 2 <= chunk.length) {
let cursor = offset + 2; const opcode = chunk[offset] & 0x0f;
if (len === 126) { const masked = (chunk[offset + 1] & 0x80) !== 0;
if (cursor + 2 > chunk.length) break; let len = chunk[offset + 1] & 0x7f;
len = chunk.readUInt16BE(cursor); let cursor = offset + 2;
cursor += 2; if (len === 126) {
} else if (len === 127) { if (cursor + 2 > chunk.length) break;
if (cursor + 8 > chunk.length) break; len = chunk.readUInt16BE(cursor);
len = Number(chunk.readBigUInt64BE(cursor)); cursor += 2;
cursor += 8; } else if (len === 127) {
} if (cursor + 8 > chunk.length) break;
let mask: Buffer | null = null; len = Number(chunk.readBigUInt64BE(cursor));
if (masked) { cursor += 8;
if (cursor + 4 > chunk.length) break; }
mask = chunk.subarray(cursor, cursor + 4); let mask: Buffer | null = null;
cursor += 4; if (masked) {
} if (cursor + 4 > chunk.length) break;
if (cursor + len > chunk.length) break; mask = chunk.subarray(cursor, cursor + 4);
let data = chunk.subarray(cursor, cursor + len); cursor += 4;
if (mask !== null) { }
const unmasked = Buffer.allocUnsafe(len); if (cursor + len > chunk.length) break;
for (let i = 0; i < len; i++) unmasked[i] = data[i] ^ mask[i % 4]; let data = chunk.subarray(cursor, cursor + len);
data = unmasked; if (mask !== null) {
} const unmasked = Buffer.allocUnsafe(len);
frames.push({ opcode, data }); for (let i = 0; i < len; i++) unmasked[i] = data[i] ^ mask[i % 4];
offset = cursor + len; data = unmasked;
} }
return { frames, consumed: offset }; frames.push({ opcode, data });
offset = cursor + len;
}
return { frames, consumed: offset };
} }
export function startMiniDaemon(getLastSeq: () => number): Promise<MiniDaemon> { export function startMiniDaemon(
const server = http.createServer(); getLastSeq: () => number,
const sockets = new Set<Duplex>(); opts: { suppressWelcome?: boolean } = {},
const daemon: MiniDaemon = { ): Promise<MiniDaemon> {
url: "", const server = http.createServer();
frames: [], const sockets = new Set<Duplex>();
authHeaders: [], const daemon: MiniDaemon = {
connections: () => sockets.size, url: "",
pushAll(text: string) { frames: [],
const frame = encodeTextFrame(text); authHeaders: [],
for (const s of sockets) s.write(frame); connections: () => sockets.size,
}, pushAll(text: string) {
dropConnections() { const frame = encodeTextFrame(text);
for (const s of sockets) s.destroy(); for (const s of sockets) s.write(frame);
sockets.clear(); },
}, dropConnections() {
close() { for (const s of sockets) s.destroy();
daemon.dropConnections(); sockets.clear();
server.close(); },
}, wedge() {
}; for (const s of sockets) s.pause();
server.on("upgrade", (req: http.IncomingMessage, socket: Duplex) => { },
daemon.authHeaders.push(String(req.headers.authorization ?? "")); unwedge() {
const key = String(req.headers["sec-websocket-key"] ?? ""); for (const s of sockets) s.resume();
const accept = createHash("sha1").update(key + WS_GUID).digest("base64"); },
socket.write( close() {
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + daemon.dropConnections();
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`, server.close();
); },
sockets.add(socket); };
// Frames can split across TCP chunks: buffer per socket and decode only server.on("upgrade", (req: http.IncomingMessage, socket: Duplex) => {
// complete frames, retaining the remainder for the next chunk. daemon.authHeaders.push(String(req.headers.authorization ?? ""));
let recvBuf = Buffer.alloc(0); const key = String(req.headers["sec-websocket-key"] ?? "");
socket.on("data", (chunk: Buffer) => { const accept = createHash("sha1")
recvBuf = Buffer.concat([recvBuf, chunk]); .update(key + WS_GUID)
const { frames: decoded, consumed } = decodeFrames(recvBuf); .digest("base64");
recvBuf = recvBuf.subarray(consumed); socket.write(
for (const f of decoded) { "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
if (f.opcode === 0x8) { `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
// proper close handshake: echo close frame, then end the socket );
socket.write(Buffer.from([0x88, 0x02, 0x03, 0xe8])); sockets.add(socket);
socket.end(); // Frames can split across TCP chunks: buffer per socket and decode only
continue; // complete frames, retaining the remainder for the next chunk.
} let recvBuf = Buffer.alloc(0);
if (f.opcode !== 0x1) continue; socket.on("data", (chunk: Buffer) => {
try { recvBuf = Buffer.concat([recvBuf, chunk]);
const frame = JSON.parse(f.data.toString("utf8")) as ClientFrame; const { frames: decoded, consumed } = decodeFrames(recvBuf);
daemon.frames.push(frame); recvBuf = recvBuf.subarray(consumed);
if (frame.type === "hello") { for (const f of decoded) {
socket.write( if (f.opcode === 0x8) {
encodeTextFrame( // proper close handshake: echo close frame, then end the socket
JSON.stringify({ socket.write(Buffer.from([0x88, 0x02, 0x03, 0xe8]));
v: 1, socket.end();
type: "welcome", continue;
sessionId: frame.sessionId, }
seq: 0, if (f.opcode !== 0x1) continue;
ts: Date.now(), try {
lastSeq: getLastSeq(), const frame = JSON.parse(f.data.toString("utf8")) as ClientFrame;
}), daemon.frames.push(frame);
), if (frame.type === "hello" && !opts.suppressWelcome) {
); socket.write(
} encodeTextFrame(
} catch { JSON.stringify({
// malformed client frame: ignore v: 1,
} type: "welcome",
} sessionId: frame.sessionId,
}); seq: 0,
socket.on("close", () => sockets.delete(socket)); ts: Date.now(),
socket.on("error", () => sockets.delete(socket)); lastSeq: getLastSeq(),
}); }),
return new Promise((resolve) => { ),
server.listen(0, "127.0.0.1", () => { );
daemon.url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}/agent/ws`; }
resolve(daemon); } catch {
}); // malformed client frame: ignore
}); }
}
});
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => sockets.delete(socket));
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
daemon.url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}/agent/ws`;
resolve(daemon);
});
});
} }
+484 -266
View File
@@ -17,312 +17,530 @@
* 6. session_shutdown -> socket closed, no reconnect afterwards * 6. session_shutdown -> socket closed, no reconnect afterwards
*/ */
import { check as rawCheck, sleep, waitFor, startMiniDaemon } from "./mini-daemon.ts"; import {
check as rawCheck,
sleep,
waitFor,
startMiniDaemon,
} from "./mini-daemon.ts";
const SESSION_ID = "sess-1"; const SESSION_ID = "sess-1";
let failures = 0; let failures = 0;
function check(name: string, ok: boolean, detail = ""): void { function check(name: string, ok: boolean, detail = ""): void {
rawCheck(name, ok, detail); rawCheck(name, ok, detail);
if (!ok) failures++; if (!ok) failures++;
} }
interface FakePi { interface FakePi {
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>; handlers: Map<string, (event: unknown, ctx: unknown) => unknown>;
sentMessages: Array<{ message: string; options: unknown }>; sentMessages: Array<{ message: string; options: unknown }>;
aborted: number; aborted: number;
on(event: string, handler: (event: unknown, ctx: unknown) => unknown): void; on(event: string, handler: (event: unknown, ctx: unknown) => unknown): void;
sendUserMessage(message: string, options?: unknown): void; sendUserMessage(message: string, options?: unknown): void;
abort(): void; abort(): void;
} }
function makeFakePi(): FakePi { function makeFakePi(): FakePi {
return { return {
handlers: new Map(), handlers: new Map(),
sentMessages: [], sentMessages: [],
aborted: 0, aborted: 0,
on(event, handler) { on(event, handler) {
this.handlers.set(event, handler); this.handlers.set(event, handler);
}, },
sendUserMessage(message, options) { sendUserMessage(message, options) {
this.sentMessages.push({ message, options }); this.sentMessages.push({ message, options });
}, },
abort() { abort() {
this.aborted++; this.aborted++;
}, },
}; };
} }
function makeFakeCtx(): unknown { function makeFakeCtx(): unknown {
return { return {
cwd: "/work/repo", cwd: "/work/repo",
model: { id: "glm-5.3", provider: "zai-renaud" }, model: { id: "glm-5.3", provider: "zai-renaud" },
sessionManager: { sessionManager: {
getSessionId: () => SESSION_ID, getSessionId: () => SESSION_ID,
getSessionName: () => undefined, getSessionName: () => undefined,
getCwd: () => "/work/repo", getCwd: () => "/work/repo",
getHeader: () => ({ timestamp: "2024-12-03T14:00:00.000Z", id: SESSION_ID }), getHeader: () => ({
}, timestamp: "2024-12-03T14:00:00.000Z",
}; id: SESSION_ID,
}),
},
};
} }
async function loadExtension(): Promise<(pi: unknown) => void> { async function loadExtension(): Promise<(pi: unknown) => void> {
const mod = (await import("./lvmh-agent.ts")) as { default: (pi: unknown) => void }; const mod = (await import("./lvmh-agent.ts")) as {
return mod.default; default: (pi: unknown) => void;
};
return mod.default;
} }
async function main(): Promise<void> { async function main(): Promise<void> {
// --- Scenario 1: env unset -> inert -------------------------------- // --- Scenario 1: env unset -> inert --------------------------------
delete process.env.LVMH_URL; delete process.env.LVMH_URL;
delete process.env.LVMH_TOKEN; delete process.env.LVMH_TOKEN;
delete process.env.LVMH_AGENT; delete process.env.LVMH_AGENT;
delete process.env.LVMH_REPO; delete process.env.LVMH_REPO;
const factory = await loadExtension(); const factory = await loadExtension();
const inertPi = makeFakePi(); const inertPi = makeFakePi();
inertPi.on = ((event: string) => { inertPi.on = ((event: string) => {
throw new Error(`inert extension registered handler ${event}`); throw new Error(`inert extension registered handler ${event}`);
}) as FakePi["on"]; }) as FakePi["on"];
factory(inertPi); // must not touch pi at all factory(inertPi); // must not touch pi at all
check("1 inert: no side effects on pi", true); check("1 inert: no side effects on pi", true);
// --- Scenario 2: live daemon --------------------------------------- // --- Scenario 2: live daemon ---------------------------------------
let welcomeLastSeq = 0; let welcomeLastSeq = 0;
const daemon = await startMiniDaemon(() => welcomeLastSeq); const daemon = await startMiniDaemon(() => welcomeLastSeq);
process.env.LVMH_URL = daemon.url; process.env.LVMH_URL = daemon.url;
process.env.LVMH_TOKEN = "smoke-token"; process.env.LVMH_TOKEN = "smoke-token";
const pi = makeFakePi(); const pi = makeFakePi();
factory(pi); factory(pi);
const handlerNames = [ const handlerNames = [
"session_start", "session_start",
"session_shutdown", "session_shutdown",
"session_info_changed", "session_info_changed",
"model_select", "model_select",
"message_start", "message_start",
"message_update", "message_update",
"message_end", "message_end",
"tool_execution_start", "tool_execution_start",
"tool_execution_update", "tool_execution_update",
"tool_execution_end", "tool_execution_end",
"agent_start", "agent_start",
"agent_end", "agent_end",
"agent_settled", "agent_settled",
]; ];
check("2 all 13 handlers registered", handlerNames.every((n) => pi.handlers.has(n))); check(
"2 all 13 handlers registered",
handlerNames.every((n) => pi.handlers.has(n)),
);
let lastReturn: unknown = "sentinel"; let lastReturn: unknown = "sentinel";
const fire = (name: string, event: unknown): void => { const fire = (name: string, event: unknown): void => {
const h = pi.handlers.get(name); const h = pi.handlers.get(name);
if (h === undefined) throw new Error(`missing handler ${name}`); if (h === undefined) throw new Error(`missing handler ${name}`);
lastReturn = h(event, makeFakeCtx()); lastReturn = h(event, makeFakeCtx());
if (lastReturn instanceof Promise) lastReturn.catch(() => undefined); if (lastReturn instanceof Promise) lastReturn.catch(() => undefined);
}; };
fire("session_start", { reason: "startup" }); fire("session_start", { reason: "startup" });
const helloSeen = await waitFor(() => daemon.frames.some((f) => f.type === "hello"), 5000); const helloSeen = await waitFor(
check("2 connects and sends hello", helloSeen); () => daemon.frames.some((f) => f.type === "hello"),
check("2 bearer auth on upgrade", daemon.authHeaders.at(-1) === "Bearer smoke-token", daemon.authHeaders.at(-1)); 5000,
);
check("2 connects and sends hello", helloSeen);
check(
"2 bearer auth on upgrade",
daemon.authHeaders.at(-1) === "Bearer smoke-token",
daemon.authHeaders.at(-1),
);
const hello = daemon.frames.find((f) => f.type === "hello"); const hello = daemon.frames.find((f) => f.type === "hello");
const hs = (hello?.session ?? {}) as Record<string, unknown>; const hs = (hello?.session ?? {}) as Record<string, unknown>;
check( check(
"2 hello snapshot", "2 hello snapshot",
hs.id === SESSION_ID && hs.id === SESSION_ID &&
hs.name === null && hs.name === null &&
hs.cwd === "/work/repo" && hs.cwd === "/work/repo" &&
hs.model === "glm-5.3" && hs.model === "glm-5.3" &&
hs.provider === "zai-renaud" && hs.provider === "zai-renaud" &&
hs.agent === false && hs.agent === false &&
hs.repo === null && hs.repo === null &&
typeof hs.startedAt === "number", typeof hs.startedAt === "number",
JSON.stringify(hs), JSON.stringify(hs),
); );
fire("message_start", { message: { role: "assistant", content: [], timestamp: 1000 } }); fire("message_start", {
fire("message_update", { message: { role: "assistant", content: [{ type: "text", text: "Hel" }], timestamp: 1000 } }); message: { role: "assistant", content: [], timestamp: 1000 },
fire("message_update", { message: { role: "assistant", content: [{ type: "text", text: "Hello world" }], timestamp: 1000 } }); });
fire("message_end", { fire("message_update", {
message: { message: {
role: "assistant", role: "assistant",
content: [ content: [{ type: "text", text: "Hel" }],
{ type: "thinking", thinking: "hmm" }, timestamp: 1000,
{ type: "text", text: "Hello world" }, },
{ type: "toolCall", id: "tc1", name: "bash", arguments: { command: "ls" } }, });
], fire("message_update", {
timestamp: 1000, message: {
}, role: "assistant",
}); content: [{ type: "text", text: "Hello world" }],
await waitFor(() => daemon.frames.some((f) => f.type === "message_end"), 2000); timestamp: 1000,
},
});
fire("message_end", {
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "hmm" },
{ type: "text", text: "Hello world" },
{
type: "toolCall",
id: "tc1",
name: "bash",
arguments: { command: "ls" },
},
],
timestamp: 1000,
},
});
await waitFor(
() => daemon.frames.some((f) => f.type === "message_end"),
2000,
);
const n0 = daemon.frames.findIndex((f) => f.type === "hello"); const n0 = daemon.frames.findIndex((f) => f.type === "hello");
const mirrored = daemon.frames.slice(n0 + 1).map((f) => f.type); const mirrored = daemon.frames.slice(n0 + 1).map((f) => f.type);
check( check(
"2 mirror order", "2 mirror order",
JSON.stringify(mirrored) === JSON.stringify(["message_start", "message_update", "message_update", "message_end"]), JSON.stringify(mirrored) ===
JSON.stringify(mirrored), JSON.stringify([
); "message_start",
const deltas = daemon.frames.filter((f) => f.type === "message_update").map((f) => f.delta); "message_update",
check("2 delta-only updates", JSON.stringify(deltas) === JSON.stringify(["Hel", "lo world"]), JSON.stringify(deltas)); "message_update",
"message_end",
]),
JSON.stringify(mirrored),
);
const deltas = daemon.frames
.filter((f) => f.type === "message_update")
.map((f) => f.delta);
check(
"2 delta-only updates",
JSON.stringify(deltas) === JSON.stringify(["Hel", "lo world"]),
JSON.stringify(deltas),
);
const msg = daemon.frames.find((f) => f.type === "message_end")?.message as Record<string, unknown>; const msg = daemon.frames.find((f) => f.type === "message_end")
const tc = (msg?.toolCalls as Array<Record<string, unknown>> | undefined)?.[0]; ?.message as Record<string, unknown>;
check( const tc = (
"2 message_end mapping", msg?.toolCalls as Array<Record<string, unknown>> | undefined
msg?.role === "assistant" && )?.[0];
msg?.text === "Hello world" && check(
msg?.thinking === "hmm" && "2 message_end mapping",
typeof msg?.id === "string" && msg?.role === "assistant" &&
tc?.id === "tc1" && msg?.text === "Hello world" &&
tc?.name === "bash" && msg?.thinking === "hmm" &&
tc?.argsJson === '{"command":"ls"}' && typeof msg?.id === "string" &&
msg?.toolCallId === null, tc?.id === "tc1" &&
JSON.stringify(msg), tc?.name === "bash" &&
); tc?.argsJson === '{"command":"ls"}' &&
msg?.toolCallId === null,
JSON.stringify(msg),
);
const big = "x".repeat(3000); const big = "x".repeat(3000);
fire("agent_start", {}); fire("agent_start", {});
fire("tool_execution_start", { toolCallId: "tc1", toolName: "bash", args: { command: "ls" } }); fire("tool_execution_start", {
fire("tool_execution_update", { toolCallId: "tc1", toolName: "bash", partialResult: { content: [{ type: "text", text: big }] } }); toolCallId: "tc1",
fire("tool_execution_end", { toolCallId: "tc1", toolName: "bash", isError: true, result: { content: [{ type: "text", text: big }] } }); toolName: "bash",
fire("agent_end", { messages: [{ role: "assistant", usage: { input: 10, output: 5, cost: { total: 0.25 } } }] }); args: { command: "ls" },
fire("agent_settled", {}); });
await waitFor(() => daemon.frames.some((f) => f.type === "agent_settled"), 2000); fire("tool_execution_update", {
toolCallId: "tc1",
toolName: "bash",
partialResult: { content: [{ type: "text", text: big }] },
});
fire("tool_execution_end", {
toolCallId: "tc1",
toolName: "bash",
isError: true,
result: { content: [{ type: "text", text: big }] },
});
fire("agent_end", {
messages: [
{
role: "assistant",
usage: { input: 10, output: 5, cost: { total: 0.25 } },
},
],
});
fire("agent_settled", {});
await waitFor(
() => daemon.frames.some((f) => f.type === "agent_settled"),
2000,
);
const partial = daemon.frames.find((f) => f.type === "tool_execution_update"); const partial = daemon.frames.find((f) => f.type === "tool_execution_update");
const end = daemon.frames.find((f) => f.type === "tool_execution_end"); const end = daemon.frames.find((f) => f.type === "tool_execution_end");
const partialLen = (partial?.partial as string | undefined)?.length; const partialLen = (partial?.partial as string | undefined)?.length;
const previewLen = (end?.resultPreview as string | undefined)?.length; const previewLen = (end?.resultPreview as string | undefined)?.length;
check("2 partial truncated to 2000", partialLen === 2000, String(partialLen)); check("2 partial truncated to 2000", partialLen === 2000, String(partialLen));
check("2 resultPreview truncated + isError", previewLen === 2000 && end?.isError === true); check(
const usage = daemon.frames.find((f) => f.type === "agent_end")?.usage as Record<string, number>; "2 resultPreview truncated + isError",
check( previewLen === 2000 && end?.isError === true,
"2 agent_end usage", );
usage?.inputTokens === 10 && usage?.outputTokens === 5 && usage?.totalCost === 0.25, const usage = daemon.frames.find((f) => f.type === "agent_end")
JSON.stringify(usage), ?.usage as Record<string, number>;
); check(
"2 agent_end usage",
usage?.inputTokens === 10 &&
usage?.outputTokens === 5 &&
usage?.totalCost === 0.25,
JSON.stringify(usage),
);
const seqs = daemon.frames.filter((f) => f.seq > 0).map((f) => f.seq); const seqs = daemon.frames.filter((f) => f.seq > 0).map((f) => f.seq);
check( check(
"2 seq strictly monotonic", "2 seq strictly monotonic",
seqs.length > 0 && seqs.every((s, i) => i === 0 || s > seqs[i - 1]), seqs.length > 0 && seqs.every((s, i) => i === 0 || s > seqs[i - 1]),
JSON.stringify(seqs), JSON.stringify(seqs),
); );
check("2 handler return values are undefined (never a promise)", lastReturn === undefined); check(
"2 handler return values are undefined (never a promise)",
lastReturn === undefined,
);
// --- Scenario 3: prompt / abort ------------------------------------ // --- Scenario 3: prompt / abort ------------------------------------
daemon.pushAll( daemon.pushAll(
JSON.stringify({ v: 1, type: "prompt", sessionId: "other-session", seq: 0, ts: Date.now(), promptId: "p0", message: "wrong session" }), JSON.stringify({
); v: 1,
daemon.pushAll( type: "prompt",
JSON.stringify({ v: 1, type: "prompt", sessionId: SESSION_ID, seq: 0, ts: Date.now(), promptId: "p1", message: "run the tests" }), sessionId: "other-session",
); seq: 0,
await waitFor(() => pi.sentMessages.length > 0, 2000); ts: Date.now(),
check( promptId: "p0",
"3 prompt delivered as steer, foreign session ignored", message: "wrong session",
pi.sentMessages.length === 1 && }),
pi.sentMessages[0].message === "run the tests" && );
JSON.stringify(pi.sentMessages[0].options) === '{"deliverAs":"steer"}', daemon.pushAll(
JSON.stringify(pi.sentMessages), JSON.stringify({
); v: 1,
daemon.pushAll(JSON.stringify({ v: 1, type: "abort", sessionId: SESSION_ID, seq: 0, ts: Date.now() })); type: "prompt",
await waitFor(() => pi.aborted > 0, 2000); sessionId: SESSION_ID,
check("3 abort calls pi.abort", pi.aborted === 1); seq: 0,
ts: Date.now(),
promptId: "p1",
message: "run the tests",
}),
);
await waitFor(() => pi.sentMessages.length > 0, 2000);
check(
"3 prompt delivered as steer, foreign session ignored",
pi.sentMessages.length === 1 &&
pi.sentMessages[0].message === "run the tests" &&
JSON.stringify(pi.sentMessages[0].options) === '{"deliverAs":"steer"}',
JSON.stringify(pi.sentMessages),
);
daemon.pushAll(
JSON.stringify({
v: 1,
type: "abort",
sessionId: SESSION_ID,
seq: 0,
ts: Date.now(),
}),
);
await waitFor(() => pi.aborted > 0, 2000);
check("3 abort calls pi.abort", pi.aborted === 1);
// --- Scenario 4: drop + reconnect, replay, overflow ---------------- // --- Scenario 4: drop + reconnect, replay, overflow ----------------
const seqBeforeDrop = Math.max(...daemon.frames.map((f) => f.seq)); const seqBeforeDrop = Math.max(...daemon.frames.map((f) => f.seq));
welcomeLastSeq = seqBeforeDrop; // daemon has everything up to the drop welcomeLastSeq = seqBeforeDrop; // daemon has everything up to the drop
daemon.dropConnections(); daemon.dropConnections();
await sleep(150); // let the client notice await sleep(150); // let the client notice
fire("message_end", { message: { role: "user", content: "offline-1", timestamp: 2000 } }); fire("message_end", {
fire("message_end", { message: { role: "user", content: "offline-2", timestamp: 2001 } }); message: { role: "user", content: "offline-1", timestamp: 2000 },
const reconnected = await waitFor( });
() => daemon.frames.filter((f) => f.type === "hello").length >= 2, fire("message_end", {
10000, message: { role: "user", content: "offline-2", timestamp: 2001 },
); });
check("4 reconnects after drop", reconnected); const reconnected = await waitFor(
const replayedTexts = daemon.frames () => daemon.frames.filter((f) => f.type === "hello").length >= 2,
.filter((f) => f.type === "message_end" && f.seq > seqBeforeDrop) 10000,
.map((f) => (f.message as { text?: string }).text); );
check( check("4 reconnects after drop", reconnected);
"4 buffered events replayed after welcome.lastSeq", const replayedTexts = daemon.frames
replayedTexts.includes("offline-1") && replayedTexts.includes("offline-2"), .filter((f) => f.type === "message_end" && f.seq > seqBeforeDrop)
JSON.stringify(replayedTexts), .map((f) => (f.message as { text?: string }).text);
); check(
const allSeqs = daemon.frames.filter((f) => f.seq > 0).map((f) => f.seq); "4 buffered events replayed after welcome.lastSeq",
const dupCount = allSeqs.length - new Set(allSeqs).size; replayedTexts.includes("offline-1") && replayedTexts.includes("offline-2"),
check("4 no duplicate seq delivery", dupCount === 0, `duplicates: ${dupCount}`); JSON.stringify(replayedTexts),
const seqsAfter = daemon.frames.filter((f) => f.seq > seqBeforeDrop).map((f) => f.seq); );
check( const allSeqs = daemon.frames.filter((f) => f.seq > 0).map((f) => f.seq);
"4 seq continues monotonically across reconnect", const dupCount = allSeqs.length - new Set(allSeqs).size;
seqsAfter.length >= 2 && seqsAfter.every((s, i) => i === 0 || s > seqsAfter[i - 1]), check(
JSON.stringify(seqsAfter), "4 no duplicate seq delivery",
); dupCount === 0,
`duplicates: ${dupCount}`,
);
const seqsAfter = daemon.frames
.filter((f) => f.seq > seqBeforeDrop)
.map((f) => f.seq);
check(
"4 seq continues monotonically across reconnect",
seqsAfter.length >= 2 &&
seqsAfter.every((s, i) => i === 0 || s > seqsAfter[i - 1]),
JSON.stringify(seqsAfter),
);
// overflow both bounded buffers, then reconnect once more // overflow both bounded buffers, then reconnect once more
const seqBeforeOverflow = Math.max(...daemon.frames.map((f) => f.seq)); const seqBeforeOverflow = Math.max(...daemon.frames.map((f) => f.seq));
welcomeLastSeq = seqBeforeOverflow; welcomeLastSeq = seqBeforeOverflow;
daemon.dropConnections(); daemon.dropConnections();
await sleep(150); await sleep(150);
for (let i = 0; i < 10050; i++) { for (let i = 0; i < 10050; i++) {
fire("agent_settled", {}); // persisted kind: fills replay buffer + send queue fire("agent_settled", {}); // persisted kind: fills replay buffer + send queue
} }
const reconnected2 = await waitFor( const reconnected2 = await waitFor(
() => daemon.frames.filter((f) => f.type === "hello").length >= 3, () => daemon.frames.filter((f) => f.type === "hello").length >= 3,
15000, 15000,
); );
check("4 reconnects after overflow window", reconnected2); check("4 reconnects after overflow window", reconnected2);
const overflowNotice = await waitFor( const overflowNotice = await waitFor(
() => daemon.frames.some((f) => f.type === "buffer_overflow"), () => daemon.frames.some((f) => f.type === "buffer_overflow"),
5000, 5000,
); );
const overflow = daemon.frames.find((f) => f.type === "buffer_overflow"); const overflow = daemon.frames.find((f) => f.type === "buffer_overflow");
check( check(
"4 buffer_overflow notice after drops", "4 buffer_overflow notice after drops",
overflowNotice && typeof (overflow?.dropped as number) === "number" && (overflow?.dropped as number) > 0, overflowNotice &&
JSON.stringify(overflow ?? null), typeof (overflow?.dropped as number) === "number" &&
); (overflow?.dropped as number) > 0,
JSON.stringify(overflow ?? null),
);
// --- Scenario 6: session_shutdown ---------------------------------- // --- Scenario 9: daemon hangs mid-handshake (no welcome) ----------
const helloCountAtShutdown = daemon.frames.filter((f) => f.type === "hello").length; // Fast watchdog knobs; read lazily by the plugin so late env wins.
fire("session_shutdown", { reason: "quit" }); process.env.LVMH_WELCOME_TIMEOUT_MS = "400";
const closed = await waitFor(() => daemon.connections() === 0, 3000); process.env.LVMH_STALL_CHECK_MS = "120";
check("6 shutdown closes connection", closed); process.env.LVMH_STALL_BYTES = "1000000";
await sleep(2600); // longer than one backoff attempt (~1-2s) const hungDaemon = await startMiniDaemon(() => 0, { suppressWelcome: true });
const helloCountAfter = daemon.frames.filter((f) => f.type === "hello").length; const prevUrl = process.env.LVMH_URL;
check("6 no reconnect after shutdown", helloCountAfter === helloCountAtShutdown); process.env.LVMH_URL = hungDaemon.url;
const pi3 = makeFakePi();
factory(pi3);
pi3.handlers.get("session_start")?.({ reason: "startup" }, makeFakeCtx());
const hungHellos = await waitFor(
() => hungDaemon.frames.filter((f) => f.type === "hello").length >= 2,
12000,
);
check(
"9 welcome timeout: abandons hung socket and retries",
hungHellos,
String(hungDaemon.frames.length),
);
pi3.handlers.get("session_shutdown")?.({ reason: "quit" }, makeFakeCtx());
hungDaemon.close();
process.env.LVMH_URL = prevUrl;
daemon.close(); // --- Scenario 10: daemon dies without TCP close (wedged socket) ----
// wedge() pauses daemon-side reads: TCP stays "open", undici keeps
// buffering sends. The plugin must detect bufferedAmount not draining
// and reconnect, or mirroring would be dead forever + memory unbounded.
// Fresh plugin instance: its stall interval inherits the fast knobs set
// in scenario 9 (interval period is fixed at first connect).
const stallDaemon = await startMiniDaemon(() => 0);
const urlBeforeStall: string | undefined = process.env.LVMH_URL;
process.env.LVMH_URL = stallDaemon.url;
const pi4 = makeFakePi();
factory(pi4);
const fire4 = (name: string, event: unknown): void => {
const h = pi4.handlers.get(name);
if (h === undefined) throw new Error(`missing handler ${name}`);
const r = h(event, makeFakeCtx());
if (r instanceof Promise) r.catch(() => undefined);
};
fire4("session_start", { reason: "startup" });
await waitFor(() => stallDaemon.frames.some((f) => f.type === "hello"), 5000);
stallDaemon.wedge();
const bigText: string = "w".repeat(1024 * 1024);
for (let i = 0; i < 60; i++) {
fire4("message_end", {
message: { role: "user", content: bigText, timestamp: 3000 + i },
});
}
const wedgedRecovered = await waitFor(
() => stallDaemon.frames.filter((f) => f.type === "hello").length >= 2,
20000,
);
check(
"10 stall watchdog: wedged socket detected, reconnected",
wedgedRecovered,
String(stallDaemon.frames.length),
);
stallDaemon.unwedge();
fire4("message_end", {
message: { role: "user", content: "after-stall", timestamp: 4000 },
});
const afterStallDelivered = await waitFor(
() =>
stallDaemon.frames.some(
(f) =>
f.type === "message_end" &&
(f.message as { text?: string })?.text === "after-stall",
),
15000,
);
check("10 mirroring works after stall recovery", afterStallDelivered);
fire4("session_shutdown", { reason: "quit" });
stallDaemon.close();
process.env.LVMH_URL = urlBeforeStall;
delete process.env.LVMH_WELCOME_TIMEOUT_MS;
delete process.env.LVMH_STALL_CHECK_MS;
delete process.env.LVMH_STALL_BYTES;
// --- Scenario 5: dead host ----------------------------------------- // --- Scenario 6: session_shutdown ----------------------------------
const deadDaemon = await startMiniDaemon(() => 0); const helloCountAtShutdown = daemon.frames.filter(
const deadUrl = deadDaemon.url; (f) => f.type === "hello",
deadDaemon.close(); // port now closed ).length;
await sleep(100); fire("session_shutdown", { reason: "quit" });
process.env.LVMH_URL = deadUrl; const closed = await waitFor(() => daemon.connections() === 0, 3000);
const pi2 = makeFakePi(); check("6 shutdown closes connection", closed);
factory(pi2); await sleep(2600); // longer than one backoff attempt (~1-2s)
pi2.handlers.get("session_start")?.({ reason: "startup" }, makeFakeCtx()); const helloCountAfter = daemon.frames.filter(
await sleep(400); (f) => f.type === "hello",
const t0 = Date.now(); ).length;
await sleep(100); check(
const loopResponsive = Date.now() - t0 < 500; "6 no reconnect after shutdown",
check("5 dead host: pi loads, event loop responsive", loopResponsive); helloCountAfter === helloCountAtShutdown,
let threw = false; );
try {
pi2.handlers.get("message_end")?.({ message: { role: "user", content: "hi", timestamp: 1 } }, makeFakeCtx());
} catch {
threw = true;
}
check("5 dead host: handlers never throw", !threw);
delete process.env.LVMH_URL; daemon.close();
delete process.env.LVMH_TOKEN;
console.log(failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`); // --- Scenario 5: dead host -----------------------------------------
process.exit(failures === 0 ? 0 : 1); const deadDaemon = await startMiniDaemon(() => 0);
const deadUrl = deadDaemon.url;
deadDaemon.close(); // port now closed
await sleep(100);
process.env.LVMH_URL = deadUrl;
const pi2 = makeFakePi();
factory(pi2);
pi2.handlers.get("session_start")?.({ reason: "startup" }, makeFakeCtx());
await sleep(400);
const t0 = Date.now();
await sleep(100);
const loopResponsive = Date.now() - t0 < 500;
check("5 dead host: pi loads, event loop responsive", loopResponsive);
let threw = false;
try {
pi2.handlers.get("message_end")?.(
{ message: { role: "user", content: "hi", timestamp: 1 } },
makeFakeCtx(),
);
} catch {
threw = true;
}
check("5 dead host: handlers never throw", !threw);
delete process.env.LVMH_URL;
delete process.env.LVMH_TOKEN;
console.log(
failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`,
);
process.exit(failures === 0 ? 0 : 1);
} }
main().catch((err: unknown) => { main().catch((err: unknown) => {
console.error("smoke harness crashed:", err); console.error("smoke harness crashed:", err);
process.exit(1); process.exit(1);
}); });