Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 36 additions & 22 deletions apps/host-daemon/test/command/thread-stop-races.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { dispatchCommand } from "../../src/command-dispatch.js";
import {
noopEventSink,
resolveRuntimeBridgeLaunch,
type CommandDispatchOptions,
type CommandOf,
} from "../../src/command-dispatch-support.js";
Expand Down Expand Up @@ -55,10 +56,10 @@ const THREAD_STOP_ACTIVE_TURN_WAIT_MS = 5_000;
interface RaceHarness {
dispatchOptions: CommandDispatchOptions;
events: ThreadEvent[];
exits: AgentRuntimeProcessExitInfo[];
/** The scripted echo bridge launch every command in this harness carries. */
launch: HostDaemonBridgeLaunch;
manager: RuntimeManager;
unexpectedProcessExit: Promise<AgentRuntimeProcessExitInfo>;
/** Every request the bridge processes handled (the provider's view). */
record: ScriptedEchoRequestRecord;
requireRuntime: () => AgentRuntime;
Expand Down Expand Up @@ -168,8 +169,15 @@ async function scriptedEchoDispatchLaunch(
async function createRaceHarness(): Promise<RaceHarness> {
const workspacePath = await makeTempDir("bb-stop-race-workspace-");
const events: ThreadEvent[] = [];
const exits: AgentRuntimeProcessExitInfo[] = [];
const record = createScriptedEchoRequestRecord();
let resolveUnexpectedProcessExit: (
info: AgentRuntimeProcessExitInfo,
) => void = () => undefined;
const unexpectedProcessExit = new Promise<AgentRuntimeProcessExitInfo>(
(resolve) => {
resolveUnexpectedProcessExit = resolve;
},
);
let runtime: AgentRuntime | null = null;
const manager = new RuntimeManager({
provisionWorkspace: async () =>
Expand All @@ -185,7 +193,9 @@ async function createRaceHarness(): Promise<RaceHarness> {
events.push(event);
},
onProcessExit: (info) => {
exits.push(info);
if (!info.expected) {
resolveUnexpectedProcessExit(info);
}
},
});
managers.push(manager);
Expand All @@ -206,7 +216,6 @@ async function createRaceHarness(): Promise<RaceHarness> {
},
}),
events,
exits,
launch: await scriptedEchoDispatchLaunch(),
manager,
record,
Expand All @@ -216,6 +225,7 @@ async function createRaceHarness(): Promise<RaceHarness> {
}
return runtime;
},
unexpectedProcessExit,
workspacePath,
};
}
Expand Down Expand Up @@ -420,13 +430,24 @@ describe("thread.stop race semantics", () => {
pluginId: "provider-crasher",
scripted: { exitAfter: "turn/start" },
});
// A healthy sibling provider keeps the environment entry alive across
// the crash, so the follow-up stop exercises the dispatch path.
await dispatchCommand(
threadStartCommand(harness, { threadId: "t-healthy" }),
const entry = await harness.manager.ensureEnvironment({
environmentId: ENVIRONMENT_ID,
workspacePath: harness.workspacePath,
workspaceProvisionType: "unmanaged",
});
const healthyLaunch = await resolveRuntimeBridgeLaunch(
harness.launch,
harness.dispatchOptions,
);
await dispatchCommand(
// A healthy sibling process keeps this same environment runtime loaded,
// so the later stop exercises its unknown-thread dispatch path. Start its
// independent bootstrap beside the crasher instead of serializing two
// real Node process startups under the test's wall-clock budget.
const healthyStart = entry.runtime.ensureProvider({
providerId: "fake",
bridgeLaunch: healthyLaunch,
});
const crashStart = dispatchCommand(
threadStartCommand(harness, {
threadId: "t-crash",
providerId: "crasher",
Expand All @@ -435,28 +456,20 @@ describe("thread.stop race semantics", () => {
}),
harness.dispatchOptions,
);
const runtime = harness.requireRuntime();
await Promise.all([healthyStart, crashStart]);

await vi.waitFor(
() => {
expect(
harness.exits.some((info) => info.providerId === "crasher"),
).toBe(true);
},
{ timeout: 5_000 },
);
const crashExit = harness.exits.find(
(info) => info.providerId === "crasher",
);
const crashExit = await harness.unexpectedProcessExit;
expect(crashExit.providerId).toBe("crasher");
// The exit snapshot proves the thread was mid-turn when the process died.
expect(crashExit?.threads).toEqual([
expect(crashExit.threads).toEqual([
expect.objectContaining({
threadId: "t-crash",
providerThreadId: "prov-1",
activeTurnId: expect.any(String),
}),
]);
// The runtime's own exit handling is the only clearing of that state.
const runtime = harness.requireRuntime();
expect(runtime.getActiveTurnId("t-crash")).toBeNull();
expect(runtime.hasThread("t-crash")).toBe(false);
// The daemon synthesized the failure for the orphaned turn.
Expand All @@ -467,6 +480,7 @@ describe("thread.stop race semantics", () => {
status: "failed",
}),
);
expect(await harness.manager.getOrAwait(ENVIRONMENT_ID)).toBe(entry);

await expect(
dispatchCommand(threadStopCommand("t-crash"), harness.dispatchOptions),
Expand Down
Loading