Files
gbrain/test/process-watchdog.serial.test.ts
T
bde11bb18f v0.42.18.0 fix: sync orphan-pileup watchdog (#1633) + links-lag µs stamp (#1768) (#1807)
* fix(extract): links_extraction_lag never clears on Postgres (#1768)

Stamp the full-microsecond updated_at (via to_char ... AT TIME ZONE UTC)
instead of the millisecond-truncated JS Date, so links_extracted_at equals
the DB updated_at exactly and the staleness predicate clears. Stamp SQL
unchanged: version-arm backdating still works, D4 preserved, CDX-1 strengthened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): out-of-band hard-deadline watchdog primitive (#1633)

Bun eval-Worker that SIGTERM->grace->SIGKILLs its own process from a separate
OS thread, so a sync whose main event loop is starved (ReDoS spin) still dies.
Signals SELF (no PID-reuse footgun). Empirically validated on Bun 1.3.13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): arm hard-deadline watchdog + graceful SIGINT cancel (#1633)

cli.ts installs the watchdog before connectEngine (bounds connect hangs);
resolveSyncHardDeadline + composeAbortSignals in sync.ts; SIGINT graceful
cancel on single-source + --all; withRefreshingLock timer unref'd. Non-TTY
default 3600s makes cron orphan-pileup structurally impossible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(claude): annotate process-watchdog + #1768/#1633 fixes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.13.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebump v0.42.13.0 -> v0.42.18.0 (queue collision)

Sibling workspaces claimed v0.42.13-v0.42.17; advance this branch's slot.
VERSION + package.json + CHANGELOG header + CLAUDE.md annotations + llms bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(key-files): current-state phrasing for #1633/#1768 entries (fix check:doc-history)

The doc-history guard bans the bolded **v0.X release-clause marker in reference
docs (history belongs in CHANGELOG + git). Rewrote the extract.ts/sync.ts
additions as current-state prose and de-versioned the process-watchdog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:00:57 -07:00

68 lines
3.1 KiB
TypeScript

/**
* Bun-pinned integration for the out-of-band watchdog (#1633, plan A4).
*
* Bun's worker_threads Worker is flagged "experimental", and the whole #1633
* fix rests on a worker timer firing + SIGKILLing the process while the MAIN
* thread is starved by a synchronous loop. These tests spawn a real harness
* process that starves its own loop and assert the watchdog kills it anyway.
*
* Serial because they use real subprocesses + wall-clock timing.
*/
import { describe, test, expect } from 'bun:test';
import { join } from 'node:path';
const HARNESS = join(import.meta.dir, 'fixtures', 'watchdog-harness.ts');
async function runHarness(
mode: string,
deadlineMs: number,
graceMs: number,
hardCapMs: number,
): Promise<{ exitCode: number | null; signalled: boolean; elapsedMs: number; stdout: string; killedByTest: boolean }> {
const proc = Bun.spawn(['bun', HARNESS, mode, String(deadlineMs), String(graceMs)], {
stdout: 'pipe',
stderr: 'pipe',
});
const start = Date.now();
let killedByTest = false;
const cap = setTimeout(() => { killedByTest = true; proc.kill('SIGKILL'); }, hardCapMs);
await proc.exited;
clearTimeout(cap);
const elapsedMs = Date.now() - start;
const stdout = await new Response(proc.stdout).text();
// Bun surfaces signal death via exitCode === null + signalCode, or a negative
// exitCode on some platforms. Treat "not a clean 0" as signalled for our purpose.
const signalled = proc.exitCode !== 0;
return { exitCode: proc.exitCode, signalled, elapsedMs, stdout, killedByTest };
}
describe('process-watchdog integration (Bun-pinned)', () => {
test('starved process IS killed by the watchdog around deadline+grace', async () => {
// deadline 300 + grace 200 = ~500ms expected death. Hard cap 4s: if the
// watchdog failed, the test's own SIGKILL fires and the assertion catches it.
const r = await runHarness('starve-with', 300, 200, 4000);
expect(r.stdout).not.toContain('SURVIVED'); // the bug symptom
expect(r.killedByTest).toBe(false); // watchdog, not the test, killed it
expect(r.signalled).toBe(true);
// Died well before the harness's 8s self-exit safety net, near deadline+grace.
expect(r.elapsedMs).toBeLessThan(3000);
}, 15000);
test('control: a starved process WITHOUT the watchdog does not self-exit', async () => {
// Proves the busy loop genuinely starves (so the death above is the watchdog).
// No watchdog installed; the test's hard cap (1.2s) is what kills it.
const r = await runHarness('starve-without', 300, 200, 1200);
expect(r.killedByTest).toBe(true); // only the test's SIGKILL stopped it
expect(r.stdout).not.toContain('SURVIVED');
}, 15000);
test('clean dispose: a disposed watchdog never kills the process', async () => {
// Long deadline, disposed immediately, process exits 0 fast and prints DISPOSED.
const r = await runHarness('clean-dispose', 60000, 60000, 5000);
expect(r.exitCode).toBe(0);
expect(r.killedByTest).toBe(false);
expect(r.stdout).toContain('DISPOSED');
expect(r.elapsedMs).toBeLessThan(4000);
}, 15000);
});