From d3074e37076615ca0e457f8b368c0230a6baeb4b Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 28 May 2026 16:49:22 +0100 Subject: [PATCH] test(ci): remove the silent-ELIFECYCLE flake investigation scaffolding (#7868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(ci): remove the silent-ELIFECYCLE investigation scaffolding The Windows backend flake is root-caused (server.ts handler gate for the in-process process.exit path; Windows pinned to Node 24.16.0 for the libuv TCP-connect overrun, tracked upstream as nodejs/node#63620). Remove the temporary diagnostics added while hunting it: - delete src/tests/backend/diagnostics.ts (per-test heartbeat + node-report snapshots) and its `--require` from the backend `test` script; - drop the `--report-on-fatalerror`/`-on-signal`/`-uncaught-exception` `NODE_OPTIONS` and the "Upload Node diagnostic reports" steps; - drop the Windows OS-level netstat/tasklist sidecar watcher. Kept: the real fixes — the log-only unhandledRejection guard in tests/backend/common.ts, the lowerCasePadIds socket-teardown tracking (comment de-referenced from the deleted file), and `pnpm test -- --exit` on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) * test: drop the obsolete report-on-fatalerror NODE_OPTIONS guard assertion The backend-tests-flake-mitigation source-lint guard required every backend step to keep the --report-on-fatalerror NODE_OPTIONS + node-report upload. Those diagnostics are removed in this PR now that the flake is root-caused, so drop that assertion. Retain the Windows-only `--exit` checks (still a live invariant) and reframe the file around the resolved root cause. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/backend-tests.yml | 147 +--------- src/package.json | 2 +- .../backend-tests-flake-mitigation.test.ts | 46 +-- src/tests/backend/diagnostics.ts | 263 ------------------ src/tests/backend/specs/lowerCasePadIds.ts | 7 +- 5 files changed, 25 insertions(+), 440 deletions(-) delete mode 100644 src/tests/backend/diagnostics.ts diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c91fa0cb1..c076a3542 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -66,24 +66,7 @@ jobs: run: pnpm build - name: Run the backend tests - env: - # --report-on-fatalerror and friends write a Node diagnostic report - # (V8 stack, libuv handles, OS info) on fatal errors that bypass JS - # handlers — the failure mode we've been chasing on Windows + Node - # 24 since PR #7663. Reports land in node-report/ and are uploaded - # as an artifact if the step fails. - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -153,19 +136,7 @@ jobs: ep_table_of_contents - name: Run the backend tests - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -229,60 +200,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - OUT="${{ github.workspace }}/node-report" - # Out-of-process OS-level watcher for the silent-ELIFECYCLE flake. - # In-process diagnostics (diagnostics.ts heartbeat + node-report - # snapshots) showed that during the death window the V8 main - # isolate is starved — heartbeat stops firing entirely, then the - # process is externally terminated, bypassing all JS handlers and - # Node's --report-on-fatalerror. To capture state during that - # starvation we need a process that doesn't depend on the dying - # process's event loop. A bash background loop polling Windows - # OS state every 500 ms gives us that: - # - netstat.log: localhost TCP socket states over time - # (TIME_WAIT/CLOSE_WAIT accumulation, handle exhaustion) - # - tasklist.log: node.exe process handle count, working set, - # CPU time — captured by the OS independent of V8. - # Both logs are appended to node-report/ which already gets - # uploaded as an artifact on failure. - ( - while true; do - ts=$(date '+%H:%M:%S.%3N') - { - echo "=== $ts ===" - netstat -an 2>/dev/null | grep -E "TCP\s+(127\.0\.0\.1|\[::1\])" || true - } >> "$OUT/netstat.log" - { - echo "=== $ts ===" - tasklist /v /fi "imagename eq node.exe" /fo csv 2>/dev/null || true - } >> "$OUT/tasklist.log" - sleep 0.5 - done - ) & - WATCHER_PID=$! - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - set +e - pnpm test -- --exit - EXIT=$? - set -e - kill "$WATCHER_PID" 2>/dev/null || true - wait "$WATCHER_PID" 2>/dev/null || true - exit $EXIT - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -374,60 +294,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - OUT="${{ github.workspace }}/node-report" - # Out-of-process OS-level watcher for the silent-ELIFECYCLE flake. - # In-process diagnostics (diagnostics.ts heartbeat + node-report - # snapshots) showed that during the death window the V8 main - # isolate is starved — heartbeat stops firing entirely, then the - # process is externally terminated, bypassing all JS handlers and - # Node's --report-on-fatalerror. To capture state during that - # starvation we need a process that doesn't depend on the dying - # process's event loop. A bash background loop polling Windows - # OS state every 500 ms gives us that: - # - netstat.log: localhost TCP socket states over time - # (TIME_WAIT/CLOSE_WAIT accumulation, handle exhaustion) - # - tasklist.log: node.exe process handle count, working set, - # CPU time — captured by the OS independent of V8. - # Both logs are appended to node-report/ which already gets - # uploaded as an artifact on failure. - ( - while true; do - ts=$(date '+%H:%M:%S.%3N') - { - echo "=== $ts ===" - netstat -an 2>/dev/null | grep -E "TCP\s+(127\.0\.0\.1|\[::1\])" || true - } >> "$OUT/netstat.log" - { - echo "=== $ts ===" - tasklist /v /fi "imagename eq node.exe" /fo csv 2>/dev/null || true - } >> "$OUT/tasklist.log" - sleep 0.5 - done - ) & - WATCHER_PID=$! - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - set +e - pnpm test -- --exit - EXIT=$? - set -e - kill "$WATCHER_PID" 2>/dev/null || true - wait "$WATCHER_PID" 2>/dev/null || true - exit $EXIT - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest diff --git a/src/package.json b/src/package.json index 8bf3d2d2d..e446fb3d9 100644 --- a/src/package.json +++ b/src/package.json @@ -149,7 +149,7 @@ }, "scripts": { "lint": "eslint .", - "test": "cross-env NODE_ENV=production mocha --import=tsx --require ./tests/backend/diagnostics.ts --timeout 120000 --extension ts --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", + "test": "cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test-utils": "cross-env NODE_ENV=production mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts", "test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api", "dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts", diff --git a/src/tests/backend-new/specs/backend-tests-flake-mitigation.test.ts b/src/tests/backend-new/specs/backend-tests-flake-mitigation.test.ts index 964e752cb..ebc8eb4d5 100644 --- a/src/tests/backend-new/specs/backend-tests-flake-mitigation.test.ts +++ b/src/tests/backend-new/specs/backend-tests-flake-mitigation.test.ts @@ -1,19 +1,19 @@ 'use strict'; -// Source-level lint pinning the Windows + Node 24 backend-test flake -// mitigations from PR #7748. Two independent attacks at the failure: +// Source-level lint pinning the one remaining Windows backend-test CI +// invariant after the silent-ELIFECYCLE flake was root-caused and fixed +// (the in-process process.exit path is gated in src/node/server.ts, and the +// Windows jobs run on Node 24.16.0 to avoid the libuv TCP-connect stack +// overrun in 24.15.0 — tracked upstream as nodejs/node#63620): // -// 1. Mocha --exit on the Windows CI jobs so the post-suite event-loop -// drain — where Windows + Node 24 hard-kills the process — never -// executes. Scoped to Windows so Linux/local runs still surface -// real handle leaks via natural drain. -// 2. NODE_OPTIONS=--report-on-fatalerror (and friends) on every -// Backend tests step, with the resulting node-report/ directory -// uploaded as an artifact on failure. If the flake recurs we -// finally get a V8 stack + libuv handle table. +// mocha --exit on the Windows CI jobs, and ONLY there, so a leaked handle +// can't hang the job at post-suite drain. Linux/local keep natural drain so +// real handle leaks stay visible. Easy to silently revert in a workflow +// refactor or leak into the shared test script; this test fails fast if it +// disappears or spreads. // -// Both pieces are easy to silently revert in a workflow refactor; this -// test fails fast if either disappears. +// (The earlier --report-on-fatalerror NODE_OPTIONS + node-report uploads were +// diagnostics for hunting the flake; removed once the cause was found.) import {readFileSync} from 'fs'; import {join} from 'path'; @@ -24,27 +24,7 @@ const read = (rel: string) => readFileSync(join(repoRoot, rel), 'utf8'); const workflow = read('.github/workflows/backend-tests.yml'); -describe('backend-tests flake mitigation (PR #7748)', () => { - it('every Backend tests step exposes Node diagnostic reports via NODE_OPTIONS', () => { - // Count the "Run the backend tests" steps so the expected-count is - // explicit — if a job is added later, this test reminds the author - // to wire the diag flags into it too. - const runStepCount = (workflow.match(/name: Run the backend tests/g) || []).length; - expect(runStepCount, 'expected 4 Backend tests step blocks (Linux × 2, Windows × 2)') - .toBe(4); - const nodeOptionsCount = (workflow.match( - /--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact/g, - ) || []).length; - expect(nodeOptionsCount, - 'every Backend tests step must set NODE_OPTIONS with the report-on-fatalerror diag flags') - .toBe(runStepCount); - const uploadCount = (workflow.match(/name: Upload Node diagnostic reports on failure/g) || []) - .length; - expect(uploadCount, - 'every Backend tests step must be followed by an Upload Node diagnostic reports step') - .toBe(runStepCount); - }); - +describe('backend-tests Windows --exit invariant', () => { it('Windows backend-test steps invoke pnpm test with --exit', () => { // --exit is the Windows-only mitigation. Linux still runs natural-drain // so leaked-handle regressions stay visible there. diff --git a/src/tests/backend/diagnostics.ts b/src/tests/backend/diagnostics.ts deleted file mode 100644 index 9c339ffb8..000000000 --- a/src/tests/backend/diagnostics.ts +++ /dev/null @@ -1,263 +0,0 @@ -'use strict'; - -// Diagnostic-only mocha bootstrap, loaded via `mocha --require ./tests/backend/diagnostics.ts`. -// -// PR #7663 added unhandledRejection / uncaughtException handlers in -// tests/backend/common.ts to surface the silent ~22% backend-test flake. -// The next failure (run 25279692065, Windows without plugins, Node 24) -// showed mocha exit with code 1 mid-suite, 261ms after the last passing -// test, with NEITHER handler firing. This means the process was killed -// in a way that bypassed JS handlers — SIGKILL, OOM, or a fatal native -// error — OR mocha itself called process.exit before the handlers ran. -// -// Subsequent runs on develop (e.g. 26311025244, Windows with plugins, -// Node 24) reproduced the same fingerprint: `[diag +0ms] diagnostics -// loaded` lands but no other diag line appears before the silent -// ELIFECYCLE. Death lands 300±50 ms after the previous test's teardown -// and the lastSeenTest pointer (only updated in afterEach) tells us -// nothing about whether the next test had started or whether mocha was -// between tests at the kill moment. -// -// This file: -// 1. Registers handlers UNCONDITIONALLY at mocha startup (common.ts is -// only imported by ~27 of 47 specs, so its handlers may register -// late or after a death-causing event). -// 2. Writes via fs.writeSync(2, ...) — synchronous stderr writes that -// complete before the kernel returns from the syscall, so the line -// lands in the runner log even if the process is killed -// milliseconds later. -// 3. Tracks BOTH the currently-running test (set in beforeEach) and the -// last-finished test (set in afterEach), so the death point can be -// bracketed even when the kill lands inside the next test's setup or -// body, before its own afterEach has a chance to update the pointer. -// 4. Emits a 1Hz heartbeat carrying the running-test name, RSS / heap -// usage, and active handle / request counts. If the process dies -// without firing any JS handler, the last heartbeat narrows the -// kill window to <=1s and the handle-count trace exposes leaks -// (sockets, timers, native bindings) that would otherwise be -// invisible at the runner-log level. -// 5. Logs exit-related events so we can discriminate: -// beforeExit + exit -> clean event-loop drain (Linux CI, local) -// only exit -> process.exit() called — expected when mocha -// is launched with --exit (the Windows CI -// jobs do this to mitigate a hard-kill flake; -// elsewhere "only exit" still means something -// else called process.exit unexpectedly) -// neither -> hard kill (SIGKILL/OOM/runner) -// signal lines -> SIGTERM / SIGINT / SIGBREAK received -// -// Drop this file once the flake's root cause is identified and fixed. - -import {writeSync} from 'node:fs'; - -const t0 = Date.now(); -let currentTest = ''; -let lastFinishedTest = ''; - -const diag = (msg: string): void => { - const line = `[diag +${Date.now() - t0}ms] ${msg}\n`; - try { - writeSync(2, line); - } catch (_) { - // Best-effort: if stderr is closed there is nothing we can do. - } -}; - -diag('diagnostics loaded'); - -// Heartbeat. unref()'d so it never holds the event loop open by itself — -// it only fires if mocha is otherwise alive. Bumped from 1 Hz to 5 Hz -// after run 26402211271 caught a death (messages.ts > USER_CHANGES > -// retransmission, kill +425 ms post-test-start) where the setTimeout-based -// mid-test snapshot didn't fire — likely Windows event-loop scheduling -// delaying setTimeout under load. setInterval has been firing reliably -// at 1 Hz throughout the investigation, so 200 ms (5 Hz) should land -// inside any death window ≥200 ms. writeReport on the Windows runner -// completed in <1 ms per the log timestamps, so the cadence cost is -// negligible compared to the precision gain. -// -// When the backend-test workflow has `--report-directory` set (only the -// Windows jobs do at time of writing), every heartbeat also writes a Node -// diagnostic report into that directory. The previous two failing CI runs -// proved the kill bypasses all JS handlers (uncaughtException, signal, -// beforeExit — none fire), so we can't capture stack state at the moment -// of death. The next-best thing is a rolling 1Hz snapshot of: -// - V8 / native call stacks (all threads) -// - libuv active handles (open TCP connections, timers, file handles) -// - JS heap statistics -// - System info (CPU, memory, environment) -// On the next failure the workflow uploads node-report/ as an artifact, -// and the latest report before the kill bracket gives us 0-1s of pre-death -// state — including, critically, whether the V8 stack is inside jose's -// JWT signing path, supertest's TCP roundtrip, or somewhere else. -// Honor NODE_REPORT_DIR as a local-repro override by pushing it into -// process.report.directory, which is the documented config knob. We can NOT -// pass an absolute path into writeReport(): on Windows the runner sets -// `--report-directory=D:\a\etherpad\etherpad/node-report` (mixed slashes), -// and Node's report writer rejects any subsequent absolute path with errno -// 22 / EINVAL. Pass a bare filename and let Node concatenate it against the -// configured directory using its own platform-correct separator. -if (process.env.NODE_REPORT_DIR && (process as any).report) { - (process as any).report.directory = process.env.NODE_REPORT_DIR; -} -const canWriteReport = - typeof (process as any).report?.writeReport === 'function' - && !!((process as any).report?.directory - || (process.env.NODE_OPTIONS || '').includes('--report-directory=')); -let reportCounter = 0; -// Throttle state is scoped to boundary (`be`) writes only. Heartbeat (`hb`) -// and mid-test (`mt`) writes use minGapMs=0 and pass `updateThrottle=false`, -// so they never bump the boundary timestamp — otherwise a slow test's -// `mt` write could land <100 ms before the next test's `beforeEach` and -// suppress that test's own `be` snapshot, exactly the boundary coverage -// the throttle is meant to preserve. -let lastBoundaryT = 0; - -// Shared writer used by the heartbeat tick, the beforeEach hook, and the -// mid-test setTimeout. minGapMs throttles `be` writes against the previous -// boundary write so a burst of fast tests doesn't produce hundreds of -// reports; `hb` and `mt` writes pass 0 + updateThrottle=false to bypass -// and not affect the throttle window. -const tryWriteReport = ( - prefix: string, - minGapMs: number, - updateThrottle = true, -): void => { - if (!canWriteReport) return; - const now = Date.now(); - if (now - lastBoundaryT < minGapMs) return; - if (updateThrottle) lastBoundaryT = now; - reportCounter += 1; - const safeTest = currentTest - .replace(/[^a-zA-Z0-9._-]+/g, '_') - .slice(0, 80); - const name = `${prefix}-${String(reportCounter).padStart(4, '0')}-${safeTest}.json`; - try { - // Bare filename only — see comment at canWriteReport definition above. - (process as any).report.writeReport(name); - } catch { /* swallow — diagnostics must not throw */ } -}; -const heartbeat = setInterval(() => { - const mem = process.memoryUsage(); - // _getActiveHandles / _getActiveRequests are undocumented Node internals. - // The earlier shape `_getActiveHandles?.().length ?? -1` was a bug: `?.()` - // only guards the call, so a missing method returns `undefined` and then - // `.length` throws TypeError — which would take down the whole test run. - // Capture the array first, then read .length only when it actually exists. - const handlesArr = (process as any)._getActiveHandles?.(); - const handles = handlesArr ? handlesArr.length : -1; - const requestsArr = (process as any)._getActiveRequests?.(); - const requests = requestsArr ? requestsArr.length : -1; - diag(`hb running="${currentTest}" lastFinished="${lastFinishedTest}" ` + - `rss=${Math.round(mem.rss / 1024 / 1024)}M ` + - `heap=${Math.round(mem.heapUsed / 1024 / 1024)}M ` + - `handles=${handles} requests=${requests}`); - // Heartbeat always writes — its cadence is the floor for snapshot density. - // updateThrottle=false so the heartbeat tick doesn't suppress the next - // beforeEach `be` write. - tryWriteReport('hb', 0, false); -}, 200); -heartbeat.unref(); - -process.on('unhandledRejection', (reason: any) => { - diag(`unhandledRejection: ${ - reason && reason.stack ? reason.stack : String(reason) - } (running="${currentTest}", lastFinished="${lastFinishedTest}")`); - // Log only — do NOT rethrow and do NOT process.exit(). Orphan rejections - // (from timed-out/abandoned async tests) belong to no awaited test, so - // rethrowing them just kills the suite. See common.ts for the full - // rationale. -}); - -process.on('uncaughtException', (err: any) => { - diag(`uncaughtException: ${ - err && err.stack ? err.stack : String(err) - } (running="${currentTest}", lastFinished="${lastFinishedTest}")`); - // Log only — do NOT process.exit(). Exiting here was part of the - // silent-ELIFECYCLE flake: a single leaked rejection killed the whole - // suite. mocha's own uncaughtException listener fails the responsible - // test and continues, which is the behavior we want. -}); - -process.on('beforeExit', (code: number) => { - diag(`beforeExit code=${code} exitCode=${process.exitCode} ` + - `running="${currentTest}" lastFinished="${lastFinishedTest}"`); -}); - -process.on('exit', (code: number) => { - diag(`exit code=${code} running="${currentTest}" lastFinished="${lastFinishedTest}"`); -}); - -for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) { - // SIGHUP / SIGBREAK don't exist on every platform; ignore registration errors. - try { - process.on(sig as any, () => { - diag(`received ${sig} (running="${currentTest}", lastFinished="${lastFinishedTest}")`); - // Let the default behavior (exit) happen. - process.exit(128); - }); - } catch (_) { - // ignore - } -} - -// Mocha root hooks — only registered if mocha picks up this file via --require. -// beforeEach sets the running pointer so a mid-test kill is attributable to a -// specific test, not just the previous one that successfully finished. -// -// We also emit a synchronous diag line on every test start. The 1Hz heartbeat -// misses tests that take less than a second, and the silent backend-test -// kills land ~300 ms after a test boundary — exactly the gap where heartbeat -// resolution fails us. A `start` line per test gives sub-millisecond -// resolution on which test was on the rails when the process died. -export const mochaHooks = { - beforeEach(this: any) { - if (this.currentTest) { - currentTest = this.currentTest.fullTitle(); - diag(`test start: ${currentTest}`); - // Drop a node-report at test-boundary granularity when the inter-report - // gap is wide enough. Run 26399285213's rerun caught the kill on the - // socketio.ts duplicate-author test, but the previous boundary write - // had landed 128 ms earlier — inside our 250 ms throttle, so the - // dying test's own beforeEach was suppressed. 100 ms is tighter than - // the inter-test cadence of fast burst suites (~2-5 ms per test, so - // ~20-50× throttled = max ~10 writes/sec) yet still captures - // boundary writes for any test whose neighbour fired ≥100 ms ago, - // including the socketio tests in the dying-test pattern. - tryWriteReport('be', 100); - // Mid-test snapshot. Run 26401801404 captured the dying test's - // beforeEach write but no further state — the kill landed 321 ms - // into the test body, between the 1 Hz heartbeat ticks, and the - // 100 ms boundary-throttle prevented additional beforeEach writes - // inside a single test. Schedule an unref'd setTimeout that fires - // 150 ms after the test entered: if it's still the running test - // at that point (i.e. slow enough that the death window applies), - // capture a snapshot from INSIDE the test body — where the TCP - // traffic and socket.io activity that precedes the kill happens. - // Fast tests (<150 ms) skip the write because currentTest will - // have already been cleared in afterEach. - // - // Gated on canWriteReport: skipping the schedule entirely when - // node-report can't be written saves a setTimeout per test on - // runs without --report-directory (local mocha runs and the - // Linux backend matrix), which would otherwise churn timers for - // no possible diagnostic output. - if (canWriteReport) { - const enteredTest = currentTest; - const midSnapshot = setTimeout(() => { - // updateThrottle=false so this snapshot doesn't push out the - // next test's `be` write — the boundary report is still the - // primary record of which test the kill struck. - if (currentTest === enteredTest) tryWriteReport('mt', 0, false); - }, 150); - midSnapshot.unref(); - } - } - }, - afterEach(this: any) { - if (this.currentTest) { - lastFinishedTest = this.currentTest.fullTitle(); - currentTest = ''; - } - }, -}; diff --git a/src/tests/backend/specs/lowerCasePadIds.ts b/src/tests/backend/specs/lowerCasePadIds.ts index 7ee29902f..9008a819a 100644 --- a/src/tests/backend/specs/lowerCasePadIds.ts +++ b/src/tests/backend/specs/lowerCasePadIds.ts @@ -20,10 +20,9 @@ describe(__filename, function () { // Track every socket.io-client we open so afterEach can disconnect each // one. Without this, the client's 5s reconnect timer stays armed after // the test server tears down, keeping the event loop alive past mocha's - // "passing" output and tripping the unclean-exit force-quit (see the - // diagnostics.ts comment block about the Windows + Node 24 backend-test - // flake; this spec was responsible for 3 of the 3 leaked timers seen - // in the wtfnode dump). + // "passing" output and tripping the unclean-exit force-quit on Windows + + // Node 24. This spec accounted for 3 of the 3 leaked timers seen in a + // wtfnode dump. const openedSockets: any[] = []; const trackSocket = (s: any) => { openedSockets.push(s); return s; };