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:
+723
-510
File diff suppressed because it is too large
Load Diff
+170
-149
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user