diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 01adafbcb..ec31113ee 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -3,7 +3,7 @@ import { constants, statSync, type BigIntStats } from "node:fs"; import { open, mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { setTimeout as delay } from "node:timers/promises"; +import { setImmediate, setTimeout as delay } from "node:timers/promises"; import type { IPty } from "@lydell/node-pty"; import type { ProcessCapture } from "../../domain/process/processCaptureParsing.js"; @@ -271,6 +271,10 @@ export const settleProcessCaptureJournal = async ( let lastChangeAt = Date.now(); while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 4)); + // A stalled event loop can leave timer and poll callbacks that were already + // due queued behind this wake-up. Drain them before judging silence so a + // trailing observation is not dropped by an early settle. + await setImmediate(); if (journal.length !== observed) { observed = journal.length; lastChangeAt = Date.now(); diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 6cc2c77a9..be0a57d21 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -274,6 +274,33 @@ it("waits for terminal observations delivered after the exit callback", async () expect(journal[1]).toMatchObject({ collection: "frames", index: 1 }); }); +it("does not settle while a trailing observation is already due behind a stalled event loop", async () => { + const journal: Array<{ + capture_order: number; + collection: "frames"; + index: number; + }> = [{ capture_order: 0, collection: "frames", index: 0 }]; + const lateFrame = new Promise((resolve) => { + setTimeout(() => { + journal.push({ capture_order: 1, collection: "frames", index: 1 }); + resolve(); + }, 12); + }); + + const wait = settleProcessCaptureJournal(journal, 20, 200); + // Stall the event loop past the quiet window so the settle wake-up and the + // trailing observation are both already due when timer callbacks resume. + const stalledUntil = Date.now() + 40; + while (Date.now() < stalledUntil) { + // Deliberately occupy the loop; no timer can run until this returns. + } + await wait; + + expect(journal).toHaveLength(2); + expect(journal[1]).toMatchObject({ collection: "frames", index: 1 }); + await lateFrame; +}); + it("keeps empty cleanup exception messages actionable in the report", async () => { const host: ProcessCaptureCleanupHost = { platform: process.platform,