From ed084b3dbb13ec9dee1adc8f5b76e784b52c00bb Mon Sep 17 00:00:00 2001 From: Jason Cat Date: Sun, 11 Oct 2026 19:24:51 +0800 Subject: [PATCH] fix(process): observe already-due trailing callbacks before settling the capture journal (#1700) A stalled event loop can leave the settle wake-up and a trailing observation callback due at the same resume. The earlier wake-up runs first, declares silence, and the capture becomes immutable before the trailing callback appends its observation. Drain the loop turn after the quiet sleep so timer and poll callbacks that were already due run before silence is judged, and cover the stalled-loop case with a deterministic regression test. Fixes #1697 --- .../capture/ProcessCaptureLifecycle.ts | 6 ++++- src/process/capture/ProcessHarness.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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,