diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index ecaeed61f6..175a82a7b9 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -15,6 +15,26 @@ function fakeParent(): Agent { return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent } +// Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on +// every start): on a contended CI runner it regularly blows past vitest's 5s +// default test timeout, observed repeatedly on the coverage lane. +vi.setConfig({ testTimeout: 30_000 }) + +/** + * `vi.waitFor` with a contention-proof timeout: the 1s default flaked + * repeatedly on the CI coverage lane, where worker-thread cold start competes + * with three sibling vitest workers for CPU. Every wait in this file is for + * something that WILL happen (a worker starting, a child registering) — a + * generous bound only removes the flake, it cannot mask a genuine hang (the + * file-wide test timeout above still fences those). + * @param assertion - retried until it stops throwing or the timeout elapses. + * @param timeout - override for a wait that must stay deliberately tight. + * @returns resolves when the assertion passes. + */ +function waitFor(assertion: () => void, timeout = 10_000): Promise { + return vi.waitFor(assertion, { timeout, interval: 50 }) +} + /** The vm-context escape hatch, spelled once: real Worker tests use it to make the WORKER misbehave. */ const ESCAPE = "globalThis.constructor.constructor('return process')()" @@ -316,7 +336,7 @@ describe('dsh-workflow-workerthread', () => { const runEnds: WorkflowResultInfo[] = [] ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) const handle = ctx.workflows.start({ ...scripted("return await agent('long job')"), parent }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) handle.cancel('user stopped it') const result = await handle.result expect(result.stopReason).toBe('cancelled') @@ -357,7 +377,7 @@ describe('dsh-workflow-workerthread', () => { const controller = new AbortController() const second = ctx.workflows.start({ ...scripted("return await agent('job')"), parent, signal: controller.signal }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) controller.abort() expect((await second.result).stopReason).toBe('cancelled') await second.dispose() @@ -400,7 +420,7 @@ describe('dsh-workflow-workerthread', () => { `), parent, }) - await vi.waitFor(() => { expect(narration).toContain('started') }) + await waitFor(() => { expect(narration).toContain('started') }) handle.cancel('raced the completion') const result = await handle.result expect(result.stopReason).toBe('cancelled') @@ -480,7 +500,7 @@ describe('dsh-workflow-workerthread', () => { }) const result = await handle.result expect(result.stopReason).toBe('completed') - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) await handle.dispose() // Not a waitFor: by the time dispose() returns, the slow child disposal // must already be complete (host-side registry quiescence). @@ -526,7 +546,7 @@ describe('dsh-workflow-workerthread', () => { expect(result.stopReason).toBe('completed') // BEFORE dispose(): the settlement itself must have aborted the signal — // without it this child would stay live until dispose's terminate. - await vi.waitFor(() => { expect(aborted).toEqual(['workflow settled']) }) + await waitFor(() => { expect(aborted).toEqual(['workflow settled']) }) await handle.dispose() }) @@ -572,9 +592,9 @@ describe('dsh-workflow-workerthread', () => { `), parent: fakeParent(), }) - await vi.waitFor(() => { expect(starts).toBe(1) }) + await waitFor(() => { expect(starts).toBe(1) }) handle.cancel('stop now') - await vi.waitFor(() => { expect(cancelled).toEqual(['stop now']) }, { timeout: 800 }) + await waitFor(() => { expect(cancelled).toEqual(['stop now']) }, 800) // The wedged worker's own completion loses to the in-flight cancel. const result = await handle.result expect(result.stopReason).toBe('cancelled') @@ -602,7 +622,7 @@ describe('dsh-workflow-workerthread', () => { `), parent, }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) const before = Date.now() await handle.dispose() // Bounded by the grace (plus the terminate), never by the 1.5s spin. @@ -625,7 +645,7 @@ describe('dsh-workflow-workerthread', () => { `), parent, }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) const handleDispose = handle.dispose() const result = await handle.result // The script itself settled (the wrapper's own dispose RPC found the @@ -663,7 +683,7 @@ describe('dsh-workflow-workerthread', () => { `), parent, }) - await vi.waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) + await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! fast.settle(text('fast done')) handle.cancel('stop now') @@ -694,7 +714,7 @@ describe('dsh-workflow-workerthread', () => { ...scripted("await parallel([() => agent('a'), () => agent('b')])\nreturn 'unreachable'"), parent, }) - await vi.waitFor(() => { expect(provider.runs.length).toBe(2) }) + await waitFor(() => { expect(provider.runs.length).toBe(2) }) handle.cancel('user stop') const result = await handle.result expect(result.stopReason).toBe('cancelled') @@ -749,7 +769,7 @@ describe('dsh-workflow-workerthread', () => { // A worker death is a stop reason like any other: workflow/end fires // with the error outcome — for a bus observer it is the only obituary. expect(runEnds).toEqual([{ stopReason: 'error', error: result.error, agentsStarted: 1 }]) - await vi.waitFor(() => { expect(cancelled.length).toBe(1) }) + await waitFor(() => { expect(cancelled.length).toBe(1) }) await handle.dispose() }, 15_000) @@ -770,7 +790,7 @@ describe('dsh-workflow-workerthread', () => { expect(result.stopReason).toBe('error') expect(result.error).toContain('worker blew up') // The reap wound the stray child down (cancel + a CLEAN dispose). - await vi.waitFor(() => { + await waitFor(() => { expect(provider.runs.length).toBe(1) expect(provider.runs[0]!.disposed).toBe(true) }) @@ -802,7 +822,7 @@ describe('dsh-workflow-workerthread', () => { `), parent, }) - await vi.waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) + await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) }) const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')! fast.settle(text('fast done')) const result = await handle.result @@ -837,7 +857,7 @@ describe('dsh-workflow-workerthread', () => { const result = await handle.result expect(result.stopReason).toBe('error') expect(result.error).toContain('exit code 5') - await vi.waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }) + await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }) await handle.dispose() }, 15_000) @@ -855,7 +875,7 @@ describe('dsh-workflow-workerthread', () => { }) const logs: string[] = [] ctx.on('workflow/log', (_info, message) => { logs.push(message) }) - await vi.waitFor(() => { expect(logs).toContain('armed') }) + await waitFor(() => { expect(logs).toContain('armed') }) handle.cancel('stop it') // The grace is deliberately huge: only the worker's own death (exit 3, // unreachable by the cancel — the script ignores hooks) settles this.