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
This commit is contained in:
Jason Cat
2026-10-11 19:24:51 +08:00
committed by GitHub
parent 66c21572de
commit ed084b3dbb
2 changed files with 32 additions and 1 deletions
@@ -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<void>((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();
@@ -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<void>((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,