Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions electron/util/responsiveTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,47 @@
import { performance } from 'node:perf_hooks';

type Timer = { unref?: () => unknown };

/** Clock and scheduler, injectable so tests can model a slow host without wall-clock thresholds. */
export type ResponsiveTimeoutHost = {
now: () => number;
setTimeout: (callback: () => void, ms: number) => Timer;
clearTimeout: (timer: Timer) => void;
};

const realHost: ResponsiveTimeoutHost = {
now: () => performance.now(),
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: timer => clearTimeout(timer as NodeJS.Timeout),
};

/** A startup budget counts only while the host can deliver the child's events.
* A long pause in the parent's loop must give queued spawn/IPC events a turn
* before the child can be declared unresponsive. A genuinely absent child still
* expires after the budget while the host is responsive. */
export function responsiveTimeout(callback: () => void, timeoutMs: number): () => void {
* expires after the budget while the host is responsive. A late tick spends at
* most two ticks of budget, so a host that is late on every tick still expires
* the budget instead of postponing it forever. */
export function responsiveTimeout(callback: () => void, timeoutMs: number, host: ResponsiveTimeoutHost = realHost): () => void {
let remaining = timeoutMs;
let last = performance.now();
let last = host.now();
let stopped = false;
let timer: NodeJS.Timeout;
const arm = () => {
const delay = Math.min(250, remaining);
timer = setTimeout(() => {
let graced = false;
let timer: Timer;
const arm = (delay: number) => {
timer = host.setTimeout(() => {
if (stopped) return;
const now = performance.now();
const now = host.now();
const elapsed = now - last;
last = now;
if (elapsed <= delay * 2) remaining -= elapsed;
if (remaining <= 0) { stopped = true; callback(); }
else arm();
const stalled = elapsed > delay * 2;
remaining -= Math.min(elapsed, delay * 2);
// A stall that exhausts the budget grants the child's events one more tick.
if (remaining <= 0 && (graced || !stalled)) { stopped = true; callback(); return; }
if (remaining <= 0) graced = true;
arm(graced ? delay : Math.min(250, remaining));
}, delay);
timer.unref?.();
};
arm();
return () => { stopped = true; clearTimeout(timer); };
arm(Math.min(250, remaining));
return () => { stopped = true; host.clearTimeout(timer); };
}
33 changes: 33 additions & 0 deletions scripts/test-document-work.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,36 @@ test('an absent child still times out while the parent is responsive', async ()
cancel();
assert.equal(expired, true);
});

/** A host whose every timer runs `lateness` times its delay, driven by hand. */
function slowHost(lateness) {
let now = 0, next = null;
return {
host: { now: () => now, setTimeout: (callback, ms) => { next = { callback, ms }; return {}; }, clearTimeout: () => { next = null; } },
tick() { const { callback, ms } = next; next = null; now += ms * lateness; callback(); },
get now() { return now; },
get armed() { return next !== null; },
};
}

test('an absent child still times out when every tick of a slow host arrives late', () => {
const clock = slowHost(3); // Each tick runs past the two-tick stall threshold.
let expired = false;
responsiveTimeout(() => { expired = true; }, 1_000, clock.host);
clock.tick(); // A 250 ms tick runs 750 ms: one stall, charged two ticks.
assert.equal(expired, false, 'a single stall leaves the child a turn');
for (let ticks = 1; clock.armed && ticks < 20; ticks++) clock.tick();
assert.equal(expired, true, 'sustained lateness must spend the budget');
assert.ok(clock.now <= 3_000, `expired after ${clock.now} ms of host time`);
});

test('a stall that exhausts the budget grants exactly one more tick', () => {
const clock = slowHost(10);
let expired = false;
responsiveTimeout(() => { expired = true; }, 40, clock.host);
clock.tick();
assert.equal(expired, false, 'spawn queued during the stall gets a turn');
assert.equal(clock.armed, true);
clock.tick();
assert.equal(expired, true);
});
Loading