Skip to content

Commit fc3bd1c

Browse files
os-zhuangclaude
andauthored
ci,test: a #4250 stall now names its own culprit; test kernels stop intercepting SIGTERM (#4250) (#4341)
Investigation results behind this change (full analysis on #4250): - Both stall-point files sit in packages/objectql, but neither the in-test drivers (pure in-memory no-ops) nor bare ObjectQL engines hold event-loop handles, lifecycle sweep timers are unref'd, and the logger is threadless -- the issue's unreleased-handle hypothesis does not fit this package's unit suites. - What DID show up: one objectql run booted 47 kernels whose gracefulShutdown default installed SIGINT/SIGTERM/SIGQUIT handlers inside vitest fork workers -- 48 SIGTERM interceptions per run, every worker recycle running a multi-kernel async shutdown race (3 of 47 graceful shutdowns never completed, cut short by another kernel's process.exit). Every path does eventually exit, so this is not proven to BE #4250 -- but it is the machinery standing closest to it, and it serves no test purpose. - Those same kernels declared `logLevel: 'silent'` -- a config key ObjectKernelConfig never had (the real shape is `logger.level`; CI type-checks spec and examples, not these tests, which is why the dead key survived). All 47 boots logged at info: ~half the package's log volume, the exact "engine init sequences" frozen at the top of both #4250 logs. Changes: - plugin.integration.test.ts / plugin.step2.test.ts: kernels boot with the real silence key (`logger: { level: 'silent' }`) and gracefulShutdown:false, and afterEach shuts down what it booted. Verified: kernel bootstrap log lines in a full run drop 47 -> 0, worker SIGTERM interceptions 48 -> 0, package log 4579 -> 2096 lines, 87 files / 1374 tests stay green. - run-with-stall-guard.mjs: on a declared stall, before killing, it now (1) samples every process in the frozen group twice via /proc and classifies each as ON-CPU (sync spin / GC thrash) or idle (awaiting something that never settles), and (2) with --report-dir set, sends SIGUSR2 so every node process whose event loop is alive dumps a diagnostic report -- digested into the log as an exact JS stack; a process that produces NO report is named as loop-blocked (verified behavior: node's report-on-signal is served by the event loop). Forensics are Linux-/proc best-effort, add at most ~6s before the kill, and never fail the kill path. - ci.yml: Test Core and Dogfood test steps arm the harvest via NODE_OPTIONS (--report-on-signal) and pass --report-dir; on failure the full reports upload as artifacts (14-day retention). The next real stall stops being a mystery: the step log will say which process froze, in which mode, and -- for the await-forever class -- on exactly which stack. Claude-Session: https://claude.ai/code/session_014NNaWXVEyG6Pv4EFRfYGg6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9555b07 commit fc3bd1c

5 files changed

Lines changed: 221 additions & 12 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
ci/test: #4250 stall forensics (SIGUSR2 stack harvest in run-with-stall-guard) + objectql kernel tests stop intercepting worker SIGTERM. Releases nothing.

.github/workflows/ci.yml

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,22 @@ jobs:
145145
# stall — a labeled red naming the last output line — instead of a
146146
# 20-minute wait for a human (or the job timeout) to notice. 10 min is
147147
# ~5× the longest healthy quiet gap and still under half a normal run.
148+
#
149+
# NODE_OPTIONS arms every node process (vitest workers included) to dump
150+
# a diagnostic report on SIGUSR2; on a stall the guard signals the frozen
151+
# process group and digests the reports into the log — exact JS stack for
152+
# a process whose event loop is alive, and a named "no report = blocked
153+
# loop" verdict for one that is sync-spinning. The next #4250 occurrence
154+
# identifies its own culprit instead of costing a diagnosis.
148155
- name: Run affected tests (PR)
149156
if: github.event_name == 'pull_request'
150157
env:
151158
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
159+
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
152160
run: |
153-
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 -- \
161+
mkdir -p "$RUNNER_TEMP/stall-reports"
162+
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
163+
--report-dir "$RUNNER_TEMP/stall-reports" -- \
154164
pnpm turbo run test --affected --filter=!@objectstack/dogfood --concurrency=4
155165
156166
# Push to main: full run. Spec's suite runs here plain (uninstrumented);
@@ -161,8 +171,12 @@ jobs:
161171
# Dogfood job runs it.
162172
- name: Run all tests (push)
163173
if: github.event_name == 'push'
174+
env:
175+
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
164176
run: |
165-
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 -- \
177+
mkdir -p "$RUNNER_TEMP/stall-reports"
178+
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
179+
--report-dir "$RUNNER_TEMP/stall-reports" -- \
166180
pnpm turbo run test --filter=!@objectstack/dogfood --concurrency=4
167181
168182
# Runs even when the suite failed — that is when it earns its keep. A red
@@ -178,6 +192,19 @@ jobs:
178192
fi
179193
node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log"
180194
195+
# A stall's full diagnostic reports (JS stacks, libuv handles, heap
196+
# summary per process) outlive the in-log digest — keep them so a #4250
197+
# occurrence can be dissected offline. Free when nothing stalled: the
198+
# directory is empty and if-no-files-found skips the upload.
199+
- name: Upload stall diagnostic reports
200+
if: failure()
201+
uses: actions/upload-artifact@v7
202+
with:
203+
name: stall-reports-test-core
204+
path: ${{ runner.temp }}/stall-reports/
205+
if-no-files-found: ignore
206+
retention-days: 14
207+
181208
# Seed the shared Turbo cache from main only (see the restore step
182209
# above). always(): keep the seed fresh even when a test fails, matching
183210
# the old actions/cache post-step behavior.
@@ -462,10 +489,15 @@ jobs:
462489
# below, propagates the suite's real exit status (no `| tee` + pipefail),
463490
# and turns frozen output into a labeled red after 10 min of silence.
464491
# Dogfood boots real engines in-process — exactly the population #4250's
465-
# stalls came from.
492+
# stalls came from. NODE_OPTIONS + --report-dir arm the stall forensics
493+
# (SIGUSR2 stack harvest) — see the Test Core comment.
466494
- name: Boot example apps and exercise real user flows
495+
env:
496+
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
467497
run: |
468-
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 -- \
498+
mkdir -p "$RUNNER_TEMP/stall-reports"
499+
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 \
500+
--report-dir "$RUNNER_TEMP/stall-reports" -- \
469501
pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/2
470502
471503
# Dogfood boots real apps in-process, so a native/OOM abort is likelier
@@ -480,6 +512,17 @@ jobs:
480512
fi
481513
node scripts/check-test-completeness.mjs "$RUNNER_TEMP/dogfood.log"
482514
515+
# Same offline-forensics artifact as Test Core; shard-scoped name so the
516+
# two matrix jobs don't collide.
517+
- name: Upload stall diagnostic reports
518+
if: failure()
519+
uses: actions/upload-artifact@v7
520+
with:
521+
name: stall-reports-dogfood-${{ matrix.shard }}
522+
path: ${{ runner.temp }}/stall-reports/
523+
if-no-files-found: ignore
524+
retention-days: 14
525+
483526
# Replaces the former auto-verify dogfood tests: runs the published
484527
# `objectstack verify` engine over each example app through the CLI —
485528
# auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant.

packages/objectql/src/plugin.integration.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
44
import { ObjectKernel } from '@objectstack/core';
55
import { ObjectQLPlugin } from '../src/plugin';
66
import { ObjectSchema } from '@objectstack/spec/data';
@@ -10,7 +10,21 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
1010
let kernel: ObjectKernel;
1111

1212
beforeEach(() => {
13-
kernel = new ObjectKernel({ logLevel: 'silent' });
13+
// logger.level — NOT `logLevel`, which ObjectKernelConfig never had: every
14+
// kernel here logged its whole bootstrap at info while declaring itself
15+
// silent, ~40 boots' worth of noise per run in the Test Core log (#4250).
16+
// gracefulShutdown:false — a test kernel must not install process-wide
17+
// SIGINT/SIGTERM handlers: vitest recycles each fork worker with SIGTERM,
18+
// and a kernel that intercepts it makes worker exit depend on the kernel's
19+
// async shutdown race instead of Node's default die-on-signal.
20+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
21+
});
22+
23+
afterEach(async () => {
24+
// Shut down what we booted: a kernel left running defers its plugins'
25+
// stop() work (engine destroy, driver disconnect) to worker teardown,
26+
// where nothing awaits or reports it.
27+
if (kernel.getState() === 'running') await kernel.shutdown();
1428
});
1529

1630
describe('Simple Mode (ObjectQL-only)', () => {

packages/objectql/src/plugin.step2.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* on objectql, even as a devDependency — turbo flags the cycle.)
1111
*/
1212

13-
import { describe, it, expect, beforeEach } from 'vitest';
13+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1414
import { ObjectKernel } from '@objectstack/core';
1515
import { createMetadataProtocolPlugin, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
1616
import { ObjectQLPlugin } from './plugin';
@@ -19,7 +19,15 @@ describe('ADR-0076 Step 2 — delegated protocol assembly', () => {
1919
let kernel: ObjectKernel;
2020

2121
beforeEach(() => {
22-
kernel = new ObjectKernel({ logLevel: 'silent' });
22+
// See plugin.integration.test.ts for both choices: `logger.level` is the
23+
// real config key (`logLevel` never existed on ObjectKernelConfig and
24+
// silenced nothing), and gracefulShutdown:false keeps process signal
25+
// handlers out of vitest's fork workers (#4250).
26+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
27+
});
28+
29+
afterEach(async () => {
30+
if (kernel.getState() === 'running') await kernel.shutdown();
2331
});
2432

2533
it('registerProtocol:false + MetadataProtocolPlugin reproduces the built-in service surface', async () => {

scripts/run-with-stall-guard.mjs

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
// (EX_TEMPFAIL: the sanctioned response is a rerun -- every #4250 occurrence
1717
// passed on rerun of the same commit).
1818
//
19-
// node scripts/run-with-stall-guard.mjs --log <file> [--stall-minutes N] -- <command...>
19+
// node scripts/run-with-stall-guard.mjs --log <file> [--stall-minutes N] \
20+
// [--report-dir <dir>] -- <command...>
2021
//
2122
// It also owns the log tee: combined stdout+stderr is forwarded to this
2223
// process's stdout AND appended to --log (which check-test-completeness.mjs
@@ -25,11 +26,33 @@
2526
// child's real exit status is propagated by construction, so there is no pipe
2627
// to guard. Do not reintroduce `| tee`.
2728
//
29+
// ## Stall forensics (before the kill)
30+
//
31+
// A declared stall triages itself instead of leaving a mystery for a human:
32+
//
33+
// 1. Every process in the command's process group is sampled twice via
34+
// /proc (state + CPU time, ~2s apart) and classified: a process that is
35+
// BURNING CPU is sync-spinning or GC-thrashing; one that is idle is
36+
// waiting on something that never settles.
37+
// 2. With --report-dir set AND the processes launched with
38+
// NODE_OPTIONS="--report-on-signal --report-signal=SIGUSR2
39+
// --report-directory=<dir>", each node process in the group gets a
40+
// SIGUSR2: a live event loop responds with a diagnostic report (exact JS
41+
// stack + open libuv handles), which is digested into the output. A node
42+
// process that produces NO report has a BLOCKED event loop -- the
43+
// no-report fact plus its CPU classification is itself the diagnosis.
44+
// (Verified: report-on-signal is served BY the event loop, so a
45+
// sync-blocked process stays silent -- that is signal, not failure.)
46+
//
47+
// Forensics are best-effort (Linux /proc; every step try/caught) and never
48+
// delay the kill by more than ~6s.
49+
//
2850
// Exit status: the child's own code when it finishes; 75 on a declared stall;
2951
// 1 when the child dies on a signal this guard did not send.
3052

3153
import { spawn } from 'node:child_process';
32-
import { createWriteStream } from 'node:fs';
54+
import { createWriteStream, readFileSync, readdirSync, existsSync } from 'node:fs';
55+
import { join } from 'node:path';
3356

3457
const STALL_EXIT_CODE = 75; // EX_TEMPFAIL
3558
const CHECK_INTERVAL_MS = 5_000;
@@ -38,11 +61,13 @@ const SIGKILL_GRACE_MS = 10_000;
3861
const argv = process.argv.slice(2);
3962
let logPath = '';
4063
let stallMinutes = 10;
64+
let reportDir = '';
4165
let command = [];
4266

4367
for (let i = 0; i < argv.length; i++) {
4468
if (argv[i] === '--log') logPath = argv[++i] ?? '';
4569
else if (argv[i] === '--stall-minutes') stallMinutes = Number(argv[++i]);
70+
else if (argv[i] === '--report-dir') reportDir = argv[++i] ?? '';
4671
else if (argv[i] === '--') {
4772
command = argv.slice(i + 1);
4873
break;
@@ -105,6 +130,113 @@ function killGroup(signal) {
105130
}
106131
}
107132

133+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
134+
135+
/** One /proc sample of every process in the child's process group.
136+
* Returns Map<pid, {comm, state, cpuTicks, rssPages, cmdline}>. */
137+
function sampleGroup() {
138+
const procs = new Map();
139+
for (const entry of readdirSync('/proc')) {
140+
if (!/^\d+$/.test(entry)) continue;
141+
const pid = Number(entry);
142+
try {
143+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
144+
// comm may contain spaces/parens -- split at the LAST ')'.
145+
const rp = stat.lastIndexOf(')');
146+
const comm = stat.slice(stat.indexOf('(') + 1, rp);
147+
const f = stat.slice(rp + 2).split(' '); // f[0]=state f[2]=pgrp f[11]=utime f[12]=stime f[21]=rss
148+
if (Number(f[2]) !== child.pid) continue;
149+
let cmdline = '';
150+
try {
151+
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
152+
} catch { /* raced with exit */ }
153+
procs.set(pid, {
154+
comm,
155+
state: f[0],
156+
cpuTicks: Number(f[11]) + Number(f[12]),
157+
rssPages: Number(f[21]),
158+
cmdline: cmdline || `[${comm}]`,
159+
});
160+
} catch { /* process vanished between readdir and read */ }
161+
}
162+
return procs;
163+
}
164+
165+
/** Classify every group member from two /proc samples and, when report-dir
166+
* plumbing is armed, harvest SIGUSR2 diagnostic reports from the node
167+
* processes. Returns the forensics text block (empty string off-Linux). */
168+
async function collectForensics() {
169+
if (!existsSync('/proc')) return '';
170+
const lines = [];
171+
try {
172+
const before = sampleGroup();
173+
await sleep(2_000);
174+
const after = sampleGroup();
175+
176+
lines.push('Process group at stall time (2s CPU sample):');
177+
for (const [pid, b] of before) {
178+
const a = after.get(pid);
179+
const cpuDelta = a ? a.cpuTicks - b.cpuTicks : 0;
180+
// 2s sample at 100Hz ticks: >20 ticks ~= >10% of a core.
181+
const verdict =
182+
a === undefined ? 'exited during sampling'
183+
: cpuDelta > 20 || a.state === 'R' ? 'ON-CPU -- sync-spinning or GC-thrashing'
184+
: 'idle -- waiting on something that never settles';
185+
const rssMb = Math.round(((a ?? b).rssPages * 4096) / 1_048_576);
186+
const cmd = b.cmdline.length > 120 ? `${b.cmdline.slice(0, 117)}...` : b.cmdline;
187+
lines.push(` pid ${pid} [${b.state}${a ? `->${a.state}` : ''}] cpuΔ=${cpuDelta} ticks rss=${rssMb}MB ${cmd}`);
188+
lines.push(` -> ${verdict}`);
189+
}
190+
191+
if (reportDir && existsSync(reportDir)) {
192+
const already = new Set(readdirSync(reportDir));
193+
const nodePids = [...after.entries()]
194+
.filter(([, p]) => /(^|\/)node(\s|$)/.test(p.cmdline) || p.comm === 'node')
195+
.map(([pid]) => pid);
196+
for (const pid of nodePids) {
197+
try { process.kill(pid, 'SIGUSR2'); } catch { /* gone */ }
198+
}
199+
await sleep(3_000);
200+
const fresh = readdirSync(reportDir).filter(
201+
(f) => f.startsWith('report.') && f.endsWith('.json') && !already.has(f),
202+
);
203+
const reportedPids = new Set();
204+
lines.push('');
205+
lines.push(`Diagnostic reports (SIGUSR2 -> ${nodePids.length} node process(es), ${fresh.length} responded):`);
206+
for (const file of fresh.slice(0, 6)) {
207+
try {
208+
const report = JSON.parse(readFileSync(join(reportDir, file), 'utf8'));
209+
const pid = report.header?.processId;
210+
reportedPids.add(pid);
211+
const js = report.javascriptStack ?? {};
212+
lines.push(
213+
` -- pid ${pid} (${file}): ${js.message || 'event loop responsive, no active JS frame -- awaiting something that never settles; check the report\'s libuv handles'}`,
214+
);
215+
for (const frame of (js.stack ?? []).slice(0, 10)) lines.push(` ${frame.trim()}`);
216+
} catch (e) {
217+
lines.push(` -- ${file}: unreadable (${e.message})`);
218+
}
219+
}
220+
for (const pid of nodePids) {
221+
if (reportedPids.has(pid)) continue;
222+
lines.push(
223+
` -- pid ${pid}: NO report -- its event loop is BLOCKED (the report is served ` +
224+
'by the loop). Cross-check its CPU verdict above: on-CPU = sync spin / GC; ' +
225+
'idle = wedged outside JS.',
226+
);
227+
}
228+
lines.push(' Full reports kept in the report dir -- upload/inspect for handles and heap.');
229+
} else if (reportDir) {
230+
lines.push(`Report dir ${reportDir} does not exist -- SIGUSR2 harvest skipped.`);
231+
} else {
232+
lines.push('No --report-dir -- SIGUSR2 stack harvest not armed for this run.');
233+
}
234+
} catch (e) {
235+
lines.push(`(forensics incomplete: ${e.message})`);
236+
}
237+
return lines.length ? `\n${lines.join('\n')}\n` : '';
238+
}
239+
108240
const watchdog = setInterval(() => {
109241
const silentMs = Date.now() - lastOutputAt;
110242
if (silentMs < stallMs) return;
@@ -126,12 +258,20 @@ ${'═'.repeat(72)}
126258
Triage: every #4250 stall so far passed on a plain rerun of the same
127259
commit -- rerun this job before suspecting the diff. If it stalls twice
128260
at the same test file, add that occurrence to #4250.
261+
262+
Collecting forensics before the kill (process states + JS stacks)...
129263
${'═'.repeat(72)}
130264
`;
131265
process.stdout.write(banner);
132266
log.write(banner);
133-
killGroup('SIGTERM');
134-
setTimeout(() => killGroup('SIGKILL'), SIGKILL_GRACE_MS).unref();
267+
void collectForensics().then((forensics) => {
268+
if (forensics) {
269+
process.stdout.write(forensics);
270+
log.write(forensics);
271+
}
272+
killGroup('SIGTERM');
273+
setTimeout(() => killGroup('SIGKILL'), SIGKILL_GRACE_MS).unref();
274+
});
135275
}, CHECK_INTERVAL_MS);
136276

137277
child.on('error', (err) => {

0 commit comments

Comments
 (0)