fix(reference-implementation): stop consent-handoff restart test from hanging node --test - #52
fix(reference-implementation): stop consent-handoff restart test from hanging node --test#52tnunamak wants to merge 6 commits into
Conversation
The regression guard polled the spawned child's stdout for Node's "spec" reporter summary line (tests N / pass N / fail N with an ℹ prefix), which only appears when stdout is a TTY. Under this repo's scripts/run-tests.ts (which spawns each test file itself, including this one, non-interactively) the grandchild this test spawns fell back to Node's "tap" reporter instead, whose summary uses a # prefix — so the guard reported a false timeout/failure even though the target file passed cleanly, confirmed against the actual CI run for PR #52 (test reference implementation job). Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
|
Rebase onto current main was blocked before the first signed commit: GPG opened interactive pinentry; cancellation caused signing to fail. The batch stopped under the requested GPG rule. This PR was not rebased. Head: 0173f2f → 0173f2f (unchanged). No new CI ran; existing checks show 7 success, 4 failure, 1 skipped. Needs work: unlock GPG and retry the rebase/CI verification. Assisted-by: AI |
|
Locally rebased onto main bac5526 with no conflicts: 0173f2f → 173a8ba6d77bf981207412889116e0871a2e14b9. Original patches are unchanged (range-diff and stable patch-ID comparison). PARKED: unattended GPG signing failed with “No pinentry”. Rewritten commits have Tim’s identity and DCO but remain unsigned locally under the owner’s fallback policy; nothing was pushed. The remote head and its old hosted CI results are unchanged; no fresh hosted checks ran. Preservation check passed: the consent-handoff test exited naturally with code 0, 15 tests passed; the separate guard also passed. Ready for owner review of local evidence; hosted checks remain pending. Assisted-by: AI |
… hanging node --test security-consent-token-handoff.test.ts's restart subtest ran two sequential startServer()/closeDb() cycles against the same real, WAL-mode SQLite file inside one node:test process. That combination trips a node:test runner defect: every assertion passes but the file never exits, reported as "Promise resolution is still pending but the event loop has already resolved" -- confirmed to be the runner's own idle-detection bookkeeping, not a real resource leak (process.report.getReport().libuv and process._getActiveHandles() are both empty when it hangs). Moves both the pre-restart and post-restart server into a genuinely separate OS process each (test/fixtures/consent-handoff-restart-server-fixture.ts, spawned twice), matching how a real restart actually works and keeping the parent node:test process free of the real-file SQLite activity that triggers the defect. Two details matter for the spawned child: stdio must be fully piped, not stdio: "inherit" for stderr (sharing the parent's real stderr fd across the spawned children reintroduces the same class of hang even with process isolation in place), and the child needs --import tsx explicitly (its import chain resolves into a workspace package under node_modules, which Node's native TypeScript stripping refuses to touch). Adds security-consent-token-handoff-clean-exit.test.ts, which spawns the target file as a child and asserts it exits within budget -- verified to fail against the pre-fix file and pass against the fix. Ported from PDP-Connect/pdpp (fix originated there before that repo's reference-implementation/ was removed in Move B); this data-connect copy is now canonical. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
check-spdx-headers.mjs requires every source file to carry the Apache-2.0 SPDX header; the two new fixture files were ported from PDP-Connect/pdpp, which doesn't enforce this check. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
The regression guard polled the spawned child's stdout for Node's "spec" reporter summary line (tests N / pass N / fail N with an ℹ prefix), which only appears when stdout is a TTY. Under this repo's scripts/run-tests.ts (which spawns each test file itself, including this one, non-interactively) the grandchild this test spawns fell back to Node's "tap" reporter instead, whose summary uses a # prefix — so the guard reported a false timeout/failure even though the target file passed cleanly, confirmed against the actual CI run for PR #52 (test reference implementation job). Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
The guard added in 45c1abf8a resolved "did not time out" as soon as the child printed a node:test summary line, or if `exitSeen` was set. The hang it exists to catch happens AFTER every assertion passes: the target prints its full "tests N / pass N / fail 0" summary and only then fails to exit. That summary is exactly what TEST_RUNNER_SUMMARY_RE matched, so the guard passed while the child was still hung, and the exitCode === 0 assertion was wrapped in `if (exitSeen)` -- skipped precisely in the hang case. Demonstrated against a fixture that prints a complete summary and then leaks a handle: the old guard passed it in 203ms with the child still alive. Makes the pass condition an actual process death, polled at the OS level with process.kill(pid, 0) so it does not depend on the nested runner's exit-event delivery, and demotes the summary line to diagnostic context in the failure message. The exitCode assertion is now unconditional, with a bounded grace window for the exit event to land after the OS already reports the process gone. Also clears the losing Promise.race branch's budget timer. Left referenced, the 45s timer held the guard process's event loop open, so every passing run stalled for the full budget -- the same leaked-handle failure mode this file is meant to police. Against the real target: duration_ms 45207 -> 9005, wall 45.24s -> 9.11s. Verified: fails (exit 1) against a post-summary-hang fixture the previous guard passed; passes (exit 1 -> 0, 15/15 in the target) against this fix. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
The port from PDP-Connect/pdpp carried that repo's formatting with it, converting security-consent-token-handoff.test.ts from the repo's space indentation to tabs and rewrapping at a narrower line width. Every sibling in reference-implementation/test/ uses spaces, and Biome under this repo's own reference-implementation/biome.jsonc (lineWidth 120) reports those siblings as already formatted, so the tabs were the outlier, not the standard. Reformats the three touched files with that config. On the target file this drops the reviewable diff from 1641 changed lines to 261 -- an 84% reduction -- and the full diff now equals the whitespace-ignoring diff, so no formatting-only noise remains. No behaviour change: `biome check` is clean on all four files in this PR, `tsc --noEmit` passes, and the target still reports 15 tests / 15 pass / 0 fail with exit 0. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
The previous formatting commit claimed no formatting-only noise remained, resting on `git diff -w` matching the full diff. That was wrong: `-w` ignores whitespace within a line but does not erase line-wrap-only changes, so single-line calls expanded into multi-line literals still counted as substantive. Classifying hunks by whitespace- and trailing-comma-insensitive content shows 12 of the target file's 21 hunks (-16 +63) carried no content change at all -- for instance the request-body and header literals near lines 388-415 and the rewrapped calls near 859-873. Biome preserves an author's existing line breaks in object literals, so running the repo's formatter did not collapse the narrower wrapping the pdpp port brought with it. Restores those 12 hunks to their main-branch form, leaving 12 semantic hunks and zero wrap-only hunks. Target file diff: 190+/71- -> 127+/55- (261 -> 182 changed lines); against the original PR head, 1641 -> 182, an 89% reduction. No behaviour change: biome check clean on all four files, tsc --noEmit passes, and the target still reports 15 tests / 15 pass / 0 fail, exit 0. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
0173f2f to
53e2129
Compare
|
Rebased onto current Three follow-up commits are added. The clean-exit guard is the test asserting that The third commit drops reformat hunks unrelated to the fix: a formatter had rewritten the whole test file, and those changes are now excluded, taking that file's diff from 1641 lines to 182. The guard was checked in both directions locally on Linux — red against the pre-fix file, green against the fix. It has not been run on any other platform, and no hosted CI result for the rebased head is in yet. Assisted-by: AI |
A single test file hanging forever stalls every CI run and every developer's local test run that includes it, since
node --test(Node's built-in test runner) never returns control once it starts.reference-implementation/test/security-consent-token-handoff.test.tshas this problem in its "an exchange code survives a SQLite-backed server restart" subtest. The file tests a consent flow where the authorization server (AS) hands the user's browser a short-lived, single-use "exchange code" — a string it later trades back for the real access token, so the token itself never has to sit in a URL or a page the browser rendered. That subtest checks the code still works after the AS restarts. All 15 assertions in the file pass, but the process never exits, andnode --testeventually reports "Promise resolution is still pending but the event loop has already resolved."The cause is a
node:testrunner defect, not a bug in the consent-flow code. The subtest simulates the AS restart by callingstartServer()twice against the same on-disk SQLite file, closing the first server before starting the second. Doing that — two real, WAL-mode (write-ahead-log) SQLite file opens inside onenode:testprocess — confuses the runner's own "has everything finished" bookkeeping. It is not a resource leak:process._getActiveHandles()(Node's list of everything still keeping the process alive — open sockets, timers, servers) andprocess.report.getReport().libuv(the same list from libuv, the lower-level event-loop library Node is built on) both report empty at the moment the process hangs. I have not filed or found an upstream Node issue matching this exactly, so treat "runner defect" as this investigation's best explanation, not a confirmed Node bug report.The fix moves both servers into their own separate OS process each, via a new fixture script (
test/fixtures/consent-handoff-restart-server-fixture.ts) spawned twice from the test — closer to a real restart anyway, since that's a new process, not the same process reopening a file. Two spawn details matter: the child'sstderrmust be piped rather than shared with the parent's real file descriptor (sharing it reintroduces the hang even with process isolation), and the child needs--import tsxexplicitly, because its imports resolve into@pdpp/polyfill-connectors, a package from this same repo's ownpackages/directory that npm workspaces (npm's monorepo feature, which symlinks local packages intonode_modulesinstead of copying them) makes available undernode_modules— and Node's built-in TypeScript loader refuses to strip types from anything undernode_modules, so it needstsx's loader instead.Added
security-consent-token-handoff-clean-exit.test.ts: it spawns the target file and asserts it exits within 45 seconds, verified to fail against the pre-fix file and pass against this fix, catching a future regression of this exact pattern.Note:
reference-implementation/'s CI gates (typecheck reference implementation,test reference implementation) carry pre-existing, already-tracked failures unrelated to this change — see issue #45. This PR does not touch or fix any of that; it should not make either gate's result any worse than main's current state.Assisted-by: AI