Files
my-pi/pi-subagents/test/child-session-shutdown.test.ts
T

188 lines
6.8 KiB
TypeScript

/**
* #242 — `runAgent` calls `session.bindExtensions()` so `session_start` fires and
* extensions can set up per-session state, but nothing ever closed that lifecycle:
* both eviction (`removeRecord`) and quit (`dispose`) called `session.dispose()`,
* which in pi only calls `ExtensionRunner.invalidate()` — it does NOT emit
* `session_shutdown`. So anything an extension armed in `session_start` (timers, fs
* watchers, sockets) leaked once per spawn, and its next tick threw `assertActive()`
* from a bare `Timeout._onTimeout` — an uncaughtException that killed interactive pi.
*
* pi emits the event itself in `AgentSessionRuntime.dispose()` before disposing; these
* tests pin that we do the same, that quit actually waits for the handlers, and that a
* hung handler can't strand the user at a dead terminal.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentManager } from "../src/agent-manager.js";
import { registerPermissionChildSession } from "../src/permission-system-bridge.js";
vi.mock("../src/agent-runner.js", () => ({
runAgent: vi.fn(),
resumeAgent: vi.fn(),
}));
vi.mock("../src/worktree.js", () => ({
createWorktree: vi.fn(),
cleanupWorktree: vi.fn(() => ({ hasChanges: false })),
pruneWorktrees: vi.fn(),
isWorktreeIsolationEnabled: vi.fn(() => true),
}));
import { runAgent } from "../src/agent-runner.js";
import { pruneWorktrees } from "../src/worktree.js";
const mockPi = {} as any;
const mockCtx = { cwd: "/tmp" } as any;
/** A child session as `runAgent` leaves it: extensions bound, so a runner with handlers. */
function boundSession(emit: (...args: any[]) => any = vi.fn(async () => {})) {
return {
dispose: vi.fn(),
extensionRunner: {
hasHandlers: vi.fn((event: string) => event === "session_shutdown"),
emit: vi.fn(emit),
},
} as any;
}
/** Spawn one background agent that resolves with `session`, and wait for it to complete. */
async function spawnCompleted(manager: AgentManager, session: any) {
vi.mocked(runAgent).mockResolvedValue({
responseText: "done",
session,
aborted: false,
steered: false,
} as any);
const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", {
description: "test",
isBackground: true,
});
await manager.getRecord(id)!.promise;
return id;
}
describe("child session shutdown (#242)", () => {
let manager: AgentManager;
afterEach(async () => {
await manager?.dispose();
vi.useRealTimers();
vi.clearAllMocks();
});
it("emits session_shutdown before disposing an evicted session", async () => {
manager = new AgentManager();
const session = boundSession();
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(session.extensionRunner.emit).toHaveBeenCalledWith({
type: "session_shutdown",
reason: "quit",
});
// Order is the whole point: after `dispose()` the runner is invalidated and
// every `ctx` getter throws, so a handler emitted afterwards is useless.
expect(session.extensionRunner.emit.mock.invocationCallOrder[0])
.toBeLessThan(session.dispose.mock.invocationCallOrder[0]);
});
it("publishes child disposal only after the session is closed", async () => {
manager = new AgentManager();
const order: string[] = [];
const pi = {
events: { emit: vi.fn((channel: string) => { order.push(channel); }) },
} as any;
const session = boundSession();
session.sessionManager = { getSessionId: () => "child-session" };
session.dispose.mockImplementation(() => { order.push("session-disposed"); });
registerPermissionChildSession(pi, session, "root-session");
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(order).toEqual([
"subagents:child:session-created",
"session-disposed",
"subagents:child:disposed",
]);
});
it("quit waits for the child's shutdown handlers to finish", async () => {
manager = new AgentManager();
let releaseHandler!: () => void;
const session = boundSession(() => new Promise<void>(r => { releaseHandler = r; }));
await spawnCompleted(manager, session);
const disposed = manager.dispose();
let settled = false;
void disposed.then(() => { settled = true; });
// Several microtask turns: enough for a fire-and-forget implementation to have
// resolved, not enough for a correctly awaited one.
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(settled).toBe(false);
expect(session.dispose).not.toHaveBeenCalled();
releaseHandler();
await disposed;
expect(settled).toBe(true);
expect(session.dispose).toHaveBeenCalledOnce();
});
it("quit is not hostage to a handler that never resolves", async () => {
manager = new AgentManager();
const session = boundSession(() => new Promise<void>(() => {}));
await spawnCompleted(manager, session);
vi.useFakeTimers();
const disposed = manager.dispose();
// Past the internal ceiling. Without it the TUI is already torn down and the
// user is left at a dead terminal with only Ctrl-C.
await vi.advanceTimersByTimeAsync(5_000);
await disposed;
expect(session.dispose).toHaveBeenCalledOnce();
// Teardown continues past the timeout rather than unwinding.
expect(pruneWorktrees).toHaveBeenCalled();
});
it("skips the emit when no extension handles session_shutdown", async () => {
manager = new AgentManager();
const session = boundSession();
session.extensionRunner.hasHandlers = vi.fn(() => false);
await spawnCompleted(manager, session);
manager.clearCompleted();
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
expect(session.extensionRunner.emit).not.toHaveBeenCalled();
});
it("degrades on a stubbed session instead of throwing", async () => {
const rejections: unknown[] = [];
const onRejection = (err: unknown) => rejections.push(err);
process.on("unhandledRejection", onRejection);
try {
manager = new AgentManager();
// No extensionRunner at all — an older pi, or a partial `onSessionCreated` stub.
const noRunner = { dispose: vi.fn() } as any;
await spawnCompleted(manager, noRunner);
manager.clearCompleted();
await vi.waitFor(() => expect(noRunner.dispose).toHaveBeenCalled());
// Nothing at all — not even dispose.
const empty = {} as any;
await spawnCompleted(manager, empty);
manager.clearCompleted();
await expect(manager.dispose()).resolves.toBeUndefined();
await new Promise(r => setImmediate(r));
expect(rejections).toEqual([]);
} finally {
process.off("unhandledRejection", onRejection);
}
});
});