/** * Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows * from `HostProcessOrchestrator`. The hosted-node suites lean on these semantics to * express races that only exist because the real class drops and rejects * handles, so this file exists to stop a later relaxation of the fake from * quietly turning one of those tests green. * * The rules the fake shares with the real class live in * `src/__tests__/orchestrator-handle-contract.ts` and run against both — here * and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the * two fails a test. The cases in this file add what only the fake has: its * recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks. * Each names the real behaviour it mirrors. */ import { describe, expect, it } from 'vitest'; import type { HostedSpawnRequest } from '../../orchestrator/types.js'; import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js'; import { FakeOrchestrator } from './fake-orchestrator.js'; const request = (containerId: string): HostedSpawnRequest => ({ containerId, partyId: 'party-P', profile: 'storage', }); describeHandleContract('FakeOrchestrator', { make: () => new FakeOrchestrator(), request, // No process to wait for: a fake child is running as soon as it exists. started: () => Promise.resolve(), failNextCreate: (orch) => { orch.failCreate = true; }, }); describe('FakeOrchestrator handle lifecycle', () => { it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => { const orch = new FakeOrchestrator(); const first = await orch.createContainer(request('hn_1')); orch.crash(first.dockerId); const second = await orch.createContainer(request('hn_1')); await expect(orch.stopContainer(first.dockerId)).rejects.toThrow( `Container not found: ${first.dockerId}`, ); // The rejected call left no trace: `stopped` records handles acted on, not // calls attempted. expect(orch.stopped).toEqual([]); // …and the container now resolves only to the new spawn. expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId); await orch.stopContainer(second.dockerId); expect(orch.stopped).toEqual([second.dockerId]); }); it('keeps the prior handle live across the whole pre-drop await window', async () => { const orch = new FakeOrchestrator(); const first = await orch.createContainer(request('hn_1')); // The invariant the hosted-node race tests rest on: a concurrent stop started // from `onCreate` still finds the old handle. Deferring it onto a microtask // puts it strictly after `onCreate` returns and strictly before the // `createDelayMs` timer, so this one case pins the drop behind BOTH — with // no dependence on wall-clock timing. The outcome is captured rather than // rethrown so a regression fails this assertion instead of surfacing as an // unhandled rejection. orch.createDelayMs = 1; let outcome: Promise | undefined; orch.onCreate = () => { outcome ??= Promise.resolve() .then(() => orch.stopContainer(first.dockerId)) .then(() => 'stopped', (err: Error) => `rejected: ${err.message}`); }; const second = await orch.createContainer(request('hn_1')); expect(await outcome).toBe('stopped'); expect(orch.stopped).toEqual([first.dockerId]); expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId); }); it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => { const orch = new FakeOrchestrator(); const first = await orch.createContainer(request('hn_1')); orch.failCreate = true; await expect(orch.createContainer(request('hn_1'))).rejects.toThrow('spawn boom'); // Host state is exactly as the failed attempt found it — which is what lets // `HostedNodeSupervisor.giveUp` still stop the child it knows about. await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined(); expect(orch.stopped).toEqual([first.dockerId]); expect(orch.resolveDockerId('hn_1')).toBe(first.dockerId); }); it('rejects a never-issued dockerId (mirrors requireHandle)', async () => { const orch = new FakeOrchestrator(); await expect(orch.removeContainer('dock_nope')).rejects.toThrow( 'Container not found: dock_nope', ); expect(orch.removed).toEqual([]); }); it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => { const orch = new FakeOrchestrator(); const { dockerId } = await orch.createContainer(request('hn_1')); await orch.removeContainer(dockerId); expect(orch.removed).toEqual([dockerId]); await expect(orch.stopContainer(dockerId)).rejects.toThrow( `Container not found: ${dockerId}`, ); expect(orch.stopped).toEqual([]); }); it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => { const orch = new FakeOrchestrator(); const attempted: string[] = []; orch.onStop = (id) => { attempted.push(id); }; await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found'); expect(attempted).toEqual(['dock_nope']); expect(orch.stopped).toEqual([]); }); it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => { const orch = new FakeOrchestrator(); await orch.createContainer(request('hn_1')); // A live handle owns that directory; only `removeContainer` — which stops // the child first — may delete it. expect(orch.reclaimWorkdir('hn_1')).toBe(false); expect(orch.reclaimedWorkdirs).toEqual([]); }); it('reclaims the workdir of a container no handle owns', async () => { const orch = new FakeOrchestrator(); // Never spawned — the stuck-`spawning` record whose host died before a // handle ever existed. expect(orch.reclaimWorkdir('hn_orphan')).toBe(true); expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan']); // …and once the container's own handle is removed, its id becomes // reclaimable too. const { dockerId } = await orch.createContainer(request('hn_1')); expect(orch.reclaimWorkdir('hn_1')).toBe(false); await orch.removeContainer(dockerId); expect(orch.reclaimWorkdir('hn_1')).toBe(true); expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan', 'hn_1']); }); it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => { const orch = new FakeOrchestrator(); const first = await orch.createContainer(request('hn_1')); orch.crash(first.dockerId); let observed: { spawned: string; resolves: string | undefined } | undefined; orch.onSpawned = (dockerId) => { observed = { spawned: dockerId, resolves: orch.resolveDockerId('hn_1') }; }; const second = await orch.createContainer(request('hn_1')); // The post-drop world: the container already resolves to the new handle, so // work driven from here sees what `abandonRespawn`'s caller would see. expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId }); expect(second.dockerId).not.toBe(first.dockerId); }); });