Skip to content

fix(reference-implementation): stop consent-handoff restart test from hanging node --test - #52

Open
tnunamak wants to merge 6 commits into
mainfrom
waspflow/hang-consent-token-handoff-port-0902
Open

fix(reference-implementation): stop consent-handoff restart test from hanging node --test#52
tnunamak wants to merge 6 commits into
mainfrom
waspflow/hang-consent-token-handoff-port-0902

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.ts has 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, and node --test eventually reports "Promise resolution is still pending but the event loop has already resolved."

The cause is a node:test runner defect, not a bug in the consent-flow code. The subtest simulates the AS restart by calling startServer() 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 one node:test process — 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) and process.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's stderr must 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 tsx explicitly, because its imports resolve into @pdpp/polyfill-connectors, a package from this same repo's own packages/ directory that npm workspaces (npm's monorepo feature, which symlinks local packages into node_modules instead of copying them) makes available under node_modules — and Node's built-in TypeScript loader refuses to strip types from anything under node_modules, so it needs tsx'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

tnunamak added a commit that referenced this pull request Sep 3, 2026
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>
@tnunamak

tnunamak commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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: 0173f2f0173f2f (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

@tnunamak

tnunamak commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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
@tnunamak
tnunamak force-pushed the waspflow/hang-consent-token-handoff-port-0902 branch from 0173f2f to 53e2129 Compare September 9, 2026 10:31
@tnunamak

tnunamak commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (bac55262f) and force-pushed. The original three commits are unchanged in content — git range-diff shows only trailer ordering differs.

Three follow-up commits are added. The clean-exit guard is the test asserting that test/security-consent-token-handoff.test.ts terminates on its own rather than hanging forever. It now requires a real process exit and fails against the pre-fix file; previously it passed even against a child process that never exited, so it was not gating the defect it exists to catch. Its 45 s timer is also cleared once the child exits, so a passing run finishes promptly instead of waiting the budget out.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant