From 0cc6d537504f33d9a92168bf91e58a680123088d Mon Sep 17 00:00:00 2001 From: Nagendhra Date: Sat, 2 May 2026 16:17:06 -0400 Subject: [PATCH 1/3] fix(browser-trace): make snapshot-loop responsive to SIGTERM and validate loop liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snapshot-loop.mjs runs a forever-loop that does a screenshot, DOM dump, URL fetch, then `await sleepMs(intervalMs)` between iterations. The SIGTERM/SIGINT handlers flip a `stopping` flag that is only consulted at the top of the while loop, so a SIGTERM that arrives during the inter-iteration sleep waits the full interval before taking effect. stop-capture.mjs sends SIGTERM and falls back to SIGKILL after roughly three seconds. With short intervals (the default of 2 s) the race is usually invisible, but with a longer `interval-seconds` (10, 30, ...) the snapshot loop is still asleep when the SIGKILL arrives. The user loses the last DOM/screenshot pair and any half-rendered files become .partial leftovers that the parent then has to sweep on shutdown. Three changes scoped to the same lifecycle concern: 1. Replace the standalone `sleepMs` with a `createStopSignal()` factory that owns the SIGTERM/SIGINT subscription and exposes an abortable `sleep(ms)`. The sleep promise resolves on either the timer firing or `stopping` flipping, so the loop wakes immediately when the user asks it to stop. `stopping` is also re-checked between major work units (screenshot, DOM, URL, append) so an in-flight tick that just missed the sleep wake-up still bails before starting more work. 2. Pass a `timeout` option (`O11Y_SNAPSHOT_TIMEOUT_MS`, default 30 s) to every `spawnSync('browse', ...)` call. A hung browse CLI used to block the loop indefinitely; with the timeout it skips the tick and moves on, so SIGTERM still gets a chance to land within the configured interval rather than the next CLI fault. 3. start-capture.mjs already verifies that `browse cdp` is alive after the one-second sanity wait. The same check is now applied to the snapshot-loop child. A syntax error or a missing dep in the loop used to leave the run with CDP events but zero DOM/screenshots, and the user only noticed after stop-capture; the new check kills CDP, prints the loop's stderr, and exits non-zero. Tests: - New `node --test scripts/snapshot-loop.test.mjs` validates the stop signal in isolation: stopping starts false, trigger flips it, sleep wakes immediately when trigger fires mid-wait, sleep resolves synchronously when trigger fired before the call, sleep without a trigger honors its interval, repeated triggers are idempotent. - Wired through `npm test` in `skills/browser-trace/package.json`. - The CLI entry now runs only when the module is invoked directly, so the test can import `createStopSignal` without booting the sampler. Local verification on Node 24 / npm 10: cd skills/browser-trace && npm test ✔ all 5 tests pass in ~190 ms. --- skills/browser-trace/package.json | 5 +- .../browser-trace/scripts/snapshot-loop.mjs | 113 +++++++++++++++--- .../scripts/snapshot-loop.test.mjs | 65 ++++++++++ .../browser-trace/scripts/start-capture.mjs | 13 +- 4 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 skills/browser-trace/scripts/snapshot-loop.test.mjs diff --git a/skills/browser-trace/package.json b/skills/browser-trace/package.json index 566b63c..1327023 100644 --- a/skills/browser-trace/package.json +++ b/skills/browser-trace/package.json @@ -2,5 +2,8 @@ "name": "browser-trace", "version": "0.1.0", "private": true, - "type": "module" + "type": "module", + "scripts": { + "test": "node --test scripts/*.test.mjs" + } } diff --git a/skills/browser-trace/scripts/snapshot-loop.mjs b/skills/browser-trace/scripts/snapshot-loop.mjs index 00b297b..c4836c8 100755 --- a/skills/browser-trace/scripts/snapshot-loop.mjs +++ b/skills/browser-trace/scripts/snapshot-loop.mjs @@ -4,41 +4,75 @@ // // Each tick opens a one-shot CDP connection via `browse --ws ...` // (bypasses the `browse` daemon so it doesn't fight the main automation). +// +// Lifecycle: stop-capture sends SIGTERM and then waits up to ~3 seconds +// before falling back to SIGKILL. The loop must therefore wake from its +// inter-iteration sleep promptly when SIGTERM arrives, otherwise long +// `interval-seconds` settings cause SIGKILL to fire mid-iteration and the +// run loses its last DOM/screenshot pair. import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { isoStampForFilename, sleepMs } from './lib.mjs'; +import { isoStampForFilename } from './lib.mjs'; -const [target, RD, intervalArg] = process.argv.slice(2); -if (!target || !RD) { - console.error('usage: snapshot-loop.mjs [interval-seconds]'); - process.exit(2); -} +// Per-call timeout for `browse --ws ...` invocations. A hung browse CLI +// would otherwise block this loop indefinitely until the parent SIGKILL +// arrives, leaving the run truncated. Tunable via env so tests / heavy +// pages can extend it. +const SNAPSHOT_TIMEOUT_MS = Number(process.env.O11Y_SNAPSHOT_TIMEOUT_MS) || 30_000; + +// Run as a CLI only when invoked directly. Letting the module be imported +// (e.g. from snapshot-loop.test.mjs) keeps the stop-signal helper unit- +// testable without booting the full sampler loop. +const isEntry = (() => { + if (!process.argv[1]) return false; + try { + return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + } catch { + return false; + } +})(); +if (isEntry) await runSampler(); + +async function runSampler() { + const [target, RD, intervalArg] = process.argv.slice(2); + if (!target || !RD) { + console.error('usage: snapshot-loop.mjs [interval-seconds]'); + process.exit(2); + } -const intervalMs = (Number(intervalArg) || 2) * 1000; -const indexPath = path.join(RD, 'index.jsonl'); + const intervalMs = (Number(intervalArg) || 2) * 1000; + const indexPath = path.join(RD, 'index.jsonl'); -let stopping = false; -process.on('SIGTERM', () => { stopping = true; }); -process.on('SIGINT', () => { stopping = true; }); + // Single shared stop signal. SIGTERM/SIGINT both flip `stopping` and + // resolve the promise so any in-flight wait can short-circuit. + const stop = createStopSignal(); -while (!stopping) { + while (!stop.stopping) { const ts = isoStampForFilename(); const png = path.join(RD, 'screenshots', `${ts}.png`); const html = path.join(RD, 'dom', `${ts}.html`); const tmp = `${html}.partial`; // Best-effort screenshot. If browse fails we just don't get one this tick. - spawnSync('browse', ['--ws', target, 'screenshot', png], { stdio: 'ignore' }); + spawnSync('browse', ['--ws', target, 'screenshot', png], { + stdio: 'ignore', + timeout: SNAPSHOT_TIMEOUT_MS, + }); if (fs.existsSync(png) && fs.statSync(png).size === 0) { fs.unlinkSync(png); } + if (stop.stopping) break; // DOM dump via temp file → rename, so we never leave a 0-byte HTML behind. try { - const r = spawnSync('browse', ['--ws', target, 'get', 'html', 'body'], { encoding: 'utf8' }); + const r = spawnSync('browse', ['--ws', target, 'get', 'html', 'body'], { + encoding: 'utf8', + timeout: SNAPSHOT_TIMEOUT_MS, + }); if (r.stdout && r.stdout.length) { fs.writeFileSync(tmp, r.stdout); fs.renameSync(tmp, html); @@ -48,18 +82,65 @@ while (!stopping) { if (fs.existsSync(tmp)) { try { fs.unlinkSync(tmp); } catch {} } + if (stop.stopping) break; // URL via the daemon-bypassing one-shot. Returns {"url": "..."}. let urlValue = ''; - const u = spawnSync('browse', ['--ws', target, '--json', 'get', 'url'], { encoding: 'utf8' }); + const u = spawnSync('browse', ['--ws', target, '--json', 'get', 'url'], { + encoding: 'utf8', + timeout: SNAPSHOT_TIMEOUT_MS, + }); if (u.stdout) { try { urlValue = JSON.parse(u.stdout).url || ''; } catch {} } + if (stop.stopping) break; const screenshotRel = fs.existsSync(png) ? `screenshots/${ts}.png` : ''; const domRel = fs.existsSync(html) ? `dom/${ts}.html` : ''; fs.appendFileSync(indexPath, JSON.stringify({ ts, screenshot: screenshotRel, dom: domRel, url: urlValue }) + '\n'); - await sleepMs(intervalMs); + await stop.sleep(intervalMs); + } +} + +// --------------------------------------------------------------------------- + +// Build a stop signal that listens once for SIGTERM/SIGINT, exposes a +// boolean view, and provides an abortable sleep so the inter-iteration +// pause wakes immediately when the user calls stop-capture. +// +// Exported as a factory rather than a module-level mutable so a test can +// drive it deterministically without messing with the live process's +// signal handlers. +export function createStopSignal() { + let stopping = false; + let resolveStop; + const stopPromise = new Promise((resolve) => { resolveStop = resolve; }); + + const trigger = () => { + if (stopping) return; + stopping = true; + resolveStop(); + }; + process.on('SIGTERM', trigger); + process.on('SIGINT', trigger); + + function sleep(ms) { + if (stopping) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + stopPromise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + return { + get stopping() { return stopping; }, + sleep, + // Test-only entry point. Production code relies on the signal handlers. + _trigger: trigger, + }; } diff --git a/skills/browser-trace/scripts/snapshot-loop.test.mjs b/skills/browser-trace/scripts/snapshot-loop.test.mjs new file mode 100644 index 0000000..070a380 --- /dev/null +++ b/skills/browser-trace/scripts/snapshot-loop.test.mjs @@ -0,0 +1,65 @@ +// Unit tests for the stop-signal helper used by snapshot-loop.mjs. +// +// Run with: node --test scripts/snapshot-loop.test.mjs +// +// The factory exposes `_trigger` so each test can stand in for the live +// SIGTERM/SIGINT handlers without touching the surrounding process. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createStopSignal } from './snapshot-loop.mjs'; + +test('stopping is false until trigger fires', () => { + const stop = createStopSignal(); + assert.equal(stop.stopping, false); + stop._trigger(); + assert.equal(stop.stopping, true); +}); + +test('sleep wakes immediately when trigger fires mid-wait', async () => { + const stop = createStopSignal(); + const startedAt = Date.now(); + const sleeping = stop.sleep(60_000); + // Fire the stop on the next tick so the sleep is actually pending. + setTimeout(() => stop._trigger(), 5); + await sleeping; + const waited = Date.now() - startedAt; + assert.ok( + waited < 1_000, + `sleep should have aborted in well under a second, but waited ${waited} ms`, + ); + assert.equal(stop.stopping, true); +}); + +test('sleep resolves immediately when trigger has already fired', async () => { + const stop = createStopSignal(); + stop._trigger(); + const startedAt = Date.now(); + await stop.sleep(60_000); + const waited = Date.now() - startedAt; + assert.ok( + waited < 100, + `sleep should have returned synchronously after stopping, but waited ${waited} ms`, + ); +}); + +test('sleep without a trigger waits for the requested interval', async () => { + const stop = createStopSignal(); + const startedAt = Date.now(); + await stop.sleep(80); + const waited = Date.now() - startedAt; + assert.ok( + waited >= 70, + `sleep should honor its interval when no trigger fires, but waited ${waited} ms`, + ); + assert.equal(stop.stopping, false); +}); + +test('repeated triggers are idempotent', () => { + const stop = createStopSignal(); + stop._trigger(); + stop._trigger(); + stop._trigger(); + assert.equal(stop.stopping, true); +}); diff --git a/skills/browser-trace/scripts/start-capture.mjs b/skills/browser-trace/scripts/start-capture.mjs index ab10185..33a533f 100755 --- a/skills/browser-trace/scripts/start-capture.mjs +++ b/skills/browser-trace/scripts/start-capture.mjs @@ -65,8 +65,11 @@ const loop = spawn(process.execPath, [loopScript, target, RD, String(interval)], loop.unref(); fs.writeFileSync(path.join(RD, '.loop.pid'), String(loop.pid)); -// Give browse cdp a beat to fail loudly on bad targets so the user sees the -// real error instead of a silent zero-event capture. +// Give both children a beat to fail loudly on bad targets so the user sees +// the real error instead of a silent zero-event capture. The snapshot loop +// is checked too: a syntax error or missing dep there would otherwise leave +// the run with CDP events but no DOM/screenshots, and the user would only +// notice after stop-capture. await sleepMs(1000); if (!isAlive(cdp.pid)) { console.error(`browse cdp exited immediately — check ${RD}/cdp/stderr.log`); @@ -74,6 +77,12 @@ if (!isAlive(cdp.pid)) { try { process.kill(loop.pid); } catch {} process.exit(1); } +if (!isAlive(loop.pid)) { + console.error(`snapshot-loop exited immediately — check ${RD}/snapshot-loop.log`); + try { console.error(fs.readFileSync(path.join(RD, 'snapshot-loop.log'), 'utf8')); } catch {} + try { process.kill(cdp.pid); } catch {} + process.exit(1); +} console.log(`run_id=${runId}`); console.log(`run_dir=${RD}`); From 2a713a59edfbab395f43f9db772af5e779fba694 Mon Sep 17 00:00:00 2001 From: Nagendhra Date: Sat, 2 May 2026 17:01:04 -0400 Subject: [PATCH 2/3] fix(browser-trace): drop dead intermediate stop checks per Cursor review Cursor Bugbot pointed out that the three intermediate `if (stop.stopping) break;` checks between the synchronous spawnSync calls in snapshot-loop's iteration body are unreachable in practice. They are right: Node only drains the signal callback queue between event-loop turns, the iteration body is fully synchronous between `await stop.sleep(intervalMs)` calls, so the SIGTERM/SIGINT handler cannot fire between two spawnSync calls. The flag can only flip during the awaited sleep, and the while-condition check at the top of the next iteration already catches that. Remove the misleading checks and replace them with a single comment that makes the contract explicit: SIGTERM responsiveness comes from the per-spawnSync timeout (each browse call returns within SNAPSHOT_TIMEOUT_MS, so the loop reaches the await within that bound) plus the abortable sleep (which wakes immediately on the signal). The previous intermediate guards gave a false impression of mid-iteration responsiveness that did not actually exist. No behavior change versus the previous commit on this branch: - Default 30 s spawnSync timeout still bounds each browse call. - Abortable sleep still wakes immediately on SIGTERM/SIGINT. - Worst-case lag from SIGTERM to loop exit is now bounded by SNAPSHOT_TIMEOUT_MS + remaining sync work in the current tick, which is the same bound the previous version actually had once you account for the dead checks. All 5 stop-signal unit tests continue to pass. --- .../browser-trace/scripts/snapshot-loop.mjs | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/skills/browser-trace/scripts/snapshot-loop.mjs b/skills/browser-trace/scripts/snapshot-loop.mjs index c4836c8..b9bc2d4 100755 --- a/skills/browser-trace/scripts/snapshot-loop.mjs +++ b/skills/browser-trace/scripts/snapshot-loop.mjs @@ -51,56 +51,60 @@ async function runSampler() { // resolve the promise so any in-flight wait can short-circuit. const stop = createStopSignal(); + // Synchronous calls inside the iteration body cannot be interrupted by + // SIGTERM mid-tick: Node only drains the signal callback queue between + // event-loop turns, and the only yield point we hit is the awaited + // sleep at the bottom. So the iteration body always runs to completion; + // SIGTERM responsiveness comes from the per-spawnSync timeout (each + // browse call returns within SNAPSHOT_TIMEOUT_MS) plus the abortable + // sleep that resolves immediately when the signal arrives. while (!stop.stopping) { - const ts = isoStampForFilename(); - const png = path.join(RD, 'screenshots', `${ts}.png`); - const html = path.join(RD, 'dom', `${ts}.html`); - const tmp = `${html}.partial`; - - // Best-effort screenshot. If browse fails we just don't get one this tick. - spawnSync('browse', ['--ws', target, 'screenshot', png], { - stdio: 'ignore', - timeout: SNAPSHOT_TIMEOUT_MS, - }); - if (fs.existsSync(png) && fs.statSync(png).size === 0) { - fs.unlinkSync(png); - } - if (stop.stopping) break; + const ts = isoStampForFilename(); + const png = path.join(RD, 'screenshots', `${ts}.png`); + const html = path.join(RD, 'dom', `${ts}.html`); + const tmp = `${html}.partial`; + + // Best-effort screenshot. If browse fails we just don't get one this tick. + spawnSync('browse', ['--ws', target, 'screenshot', png], { + stdio: 'ignore', + timeout: SNAPSHOT_TIMEOUT_MS, + }); + if (fs.existsSync(png) && fs.statSync(png).size === 0) { + fs.unlinkSync(png); + } - // DOM dump via temp file → rename, so we never leave a 0-byte HTML behind. - try { - const r = spawnSync('browse', ['--ws', target, 'get', 'html', 'body'], { + // DOM dump via temp file → rename, so we never leave a 0-byte HTML behind. + try { + const r = spawnSync('browse', ['--ws', target, 'get', 'html', 'body'], { + encoding: 'utf8', + timeout: SNAPSHOT_TIMEOUT_MS, + }); + if (r.stdout && r.stdout.length) { + fs.writeFileSync(tmp, r.stdout); + fs.renameSync(tmp, html); + } + } catch { /* best-effort */ } + // Cleanup any leftover .partial from a previous interrupted iteration. + if (fs.existsSync(tmp)) { + try { fs.unlinkSync(tmp); } catch {} + } + + // URL via the daemon-bypassing one-shot. Returns {"url": "..."}. + let urlValue = ''; + const u = spawnSync('browse', ['--ws', target, '--json', 'get', 'url'], { encoding: 'utf8', timeout: SNAPSHOT_TIMEOUT_MS, }); - if (r.stdout && r.stdout.length) { - fs.writeFileSync(tmp, r.stdout); - fs.renameSync(tmp, html); + if (u.stdout) { + try { urlValue = JSON.parse(u.stdout).url || ''; } catch {} } - } catch { /* best-effort */ } - // Cleanup any leftover .partial from a previous interrupted iteration. - if (fs.existsSync(tmp)) { - try { fs.unlinkSync(tmp); } catch {} - } - if (stop.stopping) break; - - // URL via the daemon-bypassing one-shot. Returns {"url": "..."}. - let urlValue = ''; - const u = spawnSync('browse', ['--ws', target, '--json', 'get', 'url'], { - encoding: 'utf8', - timeout: SNAPSHOT_TIMEOUT_MS, - }); - if (u.stdout) { - try { urlValue = JSON.parse(u.stdout).url || ''; } catch {} - } - if (stop.stopping) break; - const screenshotRel = fs.existsSync(png) ? `screenshots/${ts}.png` : ''; - const domRel = fs.existsSync(html) ? `dom/${ts}.html` : ''; - fs.appendFileSync(indexPath, - JSON.stringify({ ts, screenshot: screenshotRel, dom: domRel, url: urlValue }) + '\n'); + const screenshotRel = fs.existsSync(png) ? `screenshots/${ts}.png` : ''; + const domRel = fs.existsSync(html) ? `dom/${ts}.html` : ''; + fs.appendFileSync(indexPath, + JSON.stringify({ ts, screenshot: screenshotRel, dom: domRel, url: urlValue }) + '\n'); - await stop.sleep(intervalMs); + await stop.sleep(intervalMs); } } From 1b958addb46616991edb6433d9d32a0c11327f4b Mon Sep 17 00:00:00 2001 From: Nagendhra Date: Sat, 2 May 2026 17:20:04 -0400 Subject: [PATCH 3/3] fix(browser-trace): drain pending sleeps from a Set instead of chaining .then per call Cursor Bugbot pointed out that the previous `stopPromise.then(...)` inside `sleep(ms)` registered a new reaction handler on every call. The stop promise stays pending for the lifetime of the capture, so each handler closure (capturing `timer` and `resolve`) was pinned on the reaction list until SIGTERM finally fired. Over a 24-hour run at the default 2 s interval that is roughly 43,000 closures, all live in memory at once. Replace the chained-.then approach with an explicit `pending` Set: - `sleep(ms)` adds a `{ resolve, timer }` entry, then the timer's own callback removes the entry on natural wake-up. The closure becomes unreachable as soon as the sleep resolves. - `trigger()` walks the set, clears each timer, calls each resolve, and clears the set in one pass. `stopPromise` is no longer needed at all and has been dropped along with `resolveStop`. No public surface change. Two new unit tests guard the fix: - `completed sleeps deregister from the pending set so closures can be GC'd` runs 200 back-to-back 1ms sleeps and asserts pending count returns to 0 after each one. With the old code this assertion would still hold for the closure ref-count by accident (the reaction list is internal to the Promise), but the new test exercises the explicit Set so future regressions are caught directly. - `trigger drains every still-pending sleep without leaking` starts three long sleeps, asserts pending count is 3, fires the trigger, asserts it drops to 0, and awaits all three for completion. Test-only `_pendingCount()` accessor added on the returned object; documented as test-only alongside the existing `_trigger`. All 7 tests now pass; no behavior change to the loop itself. --- .../browser-trace/scripts/snapshot-loop.mjs | 29 ++++++++++++++----- .../scripts/snapshot-loop.test.mjs | 23 +++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/skills/browser-trace/scripts/snapshot-loop.mjs b/skills/browser-trace/scripts/snapshot-loop.mjs index b9bc2d4..e4bd4d2 100755 --- a/skills/browser-trace/scripts/snapshot-loop.mjs +++ b/skills/browser-trace/scripts/snapshot-loop.mjs @@ -119,13 +119,22 @@ async function runSampler() { // signal handlers. export function createStopSignal() { let stopping = false; - let resolveStop; - const stopPromise = new Promise((resolve) => { resolveStop = resolve; }); + // Pending sleeps register here so `trigger` can resolve them in one + // pass. Using an explicit set rather than `stopPromise.then(...)` per + // sleep keeps memory bounded: a 24-hour capture at the default 2 s + // interval is ~43,000 sleeps, and a chained .then handler closure per + // sleep would pin all of them on the long-lived stop promise's + // reaction list until SIGTERM finally fired. + const pending = new Set(); const trigger = () => { if (stopping) return; stopping = true; - resolveStop(); + for (const entry of pending) { + clearTimeout(entry.timer); + entry.resolve(); + } + pending.clear(); }; process.on('SIGTERM', trigger); process.on('SIGINT', trigger); @@ -133,18 +142,22 @@ export function createStopSignal() { function sleep(ms) { if (stopping) return Promise.resolve(); return new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - stopPromise.then(() => { - clearTimeout(timer); + const entry = { resolve, timer: null }; + entry.timer = setTimeout(() => { + // Self-clean on natural wake-up so the closure becomes + // unreachable as soon as the timer fires. + pending.delete(entry); resolve(); - }); + }, ms); + pending.add(entry); }); } return { get stopping() { return stopping; }, sleep, - // Test-only entry point. Production code relies on the signal handlers. + // Test-only entry points. Production code relies on the signal handlers. _trigger: trigger, + _pendingCount: () => pending.size, }; } diff --git a/skills/browser-trace/scripts/snapshot-loop.test.mjs b/skills/browser-trace/scripts/snapshot-loop.test.mjs index 070a380..2e499a7 100644 --- a/skills/browser-trace/scripts/snapshot-loop.test.mjs +++ b/skills/browser-trace/scripts/snapshot-loop.test.mjs @@ -63,3 +63,26 @@ test('repeated triggers are idempotent', () => { stop._trigger(); assert.equal(stop.stopping, true); }); + +test('completed sleeps deregister from the pending set so closures can be GC\'d', async () => { + const stop = createStopSignal(); + assert.equal(stop._pendingCount(), 0); + // Run many sleeps back-to-back so a per-sleep .then handler would + // accumulate visibly on the stop promise's reaction list. + for (let i = 0; i < 200; i++) { + const p = stop.sleep(1); + assert.equal(stop._pendingCount(), 1, `pending should be 1 mid-sleep on iteration ${i}`); + await p; + assert.equal(stop._pendingCount(), 0, `pending should drain to 0 after iteration ${i}`); + } +}); + +test('trigger drains every still-pending sleep without leaking', async () => { + const stop = createStopSignal(); + const sleeps = [stop.sleep(60_000), stop.sleep(60_000), stop.sleep(60_000)]; + assert.equal(stop._pendingCount(), 3); + stop._trigger(); + assert.equal(stop._pendingCount(), 0); + // All three resolve with no further input. + await Promise.all(sleeps); +});