Skip to content

fix(ci): stop the Node 24.21 healthcheck hang and cap job runtimes - #267

Merged
intech merged 1 commit into
mainfrom
feat/node24-shutdown-hang
Sep 28, 2026
Merged

intech merged 1 commit into
mainfrom
feat/node24-shutdown-hang

Conversation

@intech

@intech intech commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The Node 24 CI job hangs on every PR until GitHub's 6-hour limit. node-version: "24" now resolves to v24.21.0, whose node:http2 client defers RST_STREAM while receiving (nodejs/node#65093, a v24-only adaptation of #64166). A stream closed with req.close() from its 'response' handler before its body is read is never destroyed, so the client keeps its TCP connection open. The healthcheck test helper httpStatus uses exactly that pattern. As a result, the server's graceful stop() never completes and the integration suite never exits.

The regression was reproduced on plain node:http2 without Connectum: 24.20.0 exits, 24.21.0 hangs, 26.10.0 exits. It is a client-side issue; the server's Node version is irrelevant.

  • Test. worker-components.test.ts httpStatus drains the response body before closing the session.
  • CI. Every workflow job gets timeout-minutes: 15, and 30 for "Release & Publish". The slowest job over 224 successful runs took 116 s, so a hang now fails in minutes instead of hours.

A related server-side reliability gap is not addressed here and will get its own PR: after shutdown.timeout, Connectum destroys HTTP/2 sessions but not raw sockets, so the process does not exit while a client holds TCP.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (documented in migration guide)
  • Documentation / chore / internal

Test plan

  • healthcheck integration suite in Docker: 16/16 with exit in about 1 s on node:24.20.0, node:24.21.0 and node:26.10.0. Before the fix, 24.21.0 hung.
  • pnpm build && pnpm typecheck && pnpm test pass locally (33/33 turbo tasks)
  • pnpm lint passes locally. The only warning, packages/events/src/broadcast.ts:18, pre-exists on main.
  • pnpm release:contract passes (release.yml gains only timeout-minutes)
  • Every job has timeout-minutes, checked with yq over all 10 workflow files.
  • CI "Node 24" green on this PR is the end-to-end check.

Parity coverage

  • Parity coverage added
  • Parity N/A: test helper and CI configuration only, no RPC behaviour changes.

Related issues / changes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Automated checks and maintenance workflows now have a 15-minute time limit, and release publishing has a 30-minute limit.
    • Health-check integration tests now wait for HTTP/2 responses to finish before reporting the status.

The "Node 24" job resolves to v24.21.0, whose http2 client defers
RST_STREAM while receiving (nodejs/node#65093): a stream closed from its
'response' handler before the body is read is never destroyed, so the
client keeps its TCP connection open, the server's graceful stop() never
completes and the healthcheck integration suite hangs until GitHub's
6-hour limit.

- The worker-components httpStatus helper drains the body before closing
  the session (16/16, exits in ~1 s on Node 24.20.0, 24.21.0 and 26.10.0).
- Every workflow job gets timeout-minutes (15; 30 for the npm release job;
  the slowest job measured over 224 successful runs took 116 s), so a hang
  fails in minutes instead of holding a runner for hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

The latest Buf updates on your PR. Results from workflow Buf Proto / lint (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 28, 2026, 10:48 AM

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 93a35724-095a-4c6f-b6ae-cbe225c9e260

📥 Commits

Reviewing files that changed from the base of the PR and between 937cc0f and 490350f.

📒 Files selected for processing (11)
  • .github/workflows/auto-assign.yml
  • .github/workflows/auto-label.yml
  • .github/workflows/bsr-publish.yml
  • .github/workflows/buf.yml
  • .github/workflows/ci.yml
  • .github/workflows/cli-scaffold-matrix.yml
  • .github/workflows/create-release.yml
  • .github/workflows/parity-gate.yml
  • .github/workflows/release.yml
  • .github/workflows/snapshot.yml
  • packages/healthcheck/tests/integration/worker-components.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Workflow jobs now have execution timeouts. The healthcheck integration test helper now waits for the HTTP/2 response stream to end before closing its session and returning the captured status.

Changes

Workflow Job Timeouts

Layer / File(s) Summary
Set workflow job timeout caps
.github/workflows/*.yml
Workflow jobs receive 15-minute timeouts. The release job receives a 30-minute timeout, with a comment describing the longer limit. No labeling logic, job steps, or triggers change.

HTTP/2 Response Test Handling

Layer / File(s) Summary
Wait for response stream completion
packages/healthcheck/tests/integration/worker-components.test.ts
The httpStatus helper resumes the response stream and waits for end before closing the session and resolving with the captured status.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 49035

The workflow limits bound job runtimes, and the healthcheck helper waits for complete responses before closing its session. Available evidence establishes no ordinary-path regression or concrete merge-blocking impact.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 49035

The change does not show a new production entry point or a verified security issue. The main design risk is that a release could be stopped after publication starts but before its accompanying release and registry updates finish. No evidence shows that normal releases approach the new deadline.

Retained concerns

  • Low · reliability · inferred: The new 30-minute job cutoff can interrupt publication before the conditional draft-release and BSR updates complete. If publication has already changed external state, the workflow has no explicit timeout-reconciliation step; whether the external publisher can safely resume is unverified.
Security review details

Security Blast Radius

  • inferred — The changed release deadline applies to a privileged job that may publish the workspace's packages and update GitHub and BSR release state. It does not itself grant a new caller those privileges; the possible added exposure is inconsistent external release state after interruption.

Trust Boundaries and Controls

  • inferred — The changed HTTP/2 client is confined to a test helper with fixed /healthz callers. The apparent public-API classification does not establish a new attacker-reachable production entry point, and the release workflow's existing trigger and credentials are unchanged by its timeout addition.

Resilience and Maintainability Implications

  • inferred — A timeout after an external publication but before conditional follow-up steps is the relevant failure-containment question. The workflow's existing draft-release check covers one repeated action, not a demonstrated end-to-end reconciliation of npm, GitHub, and BSR state.

Hardening Proposals

  • proposed — Validate the deadline against complete release runs and define how an interrupted publication is detected and reconciled across package, GitHub release, and BSR state before relying on reruns.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Node 24.21 healthcheck hang and the addition of workflow runtime limits.
Description check ✅ Passed The description includes all required template sections, explains the cause and fix, documents validation steps, selects the change type, and justifies why parity coverage is not applicable.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files. (10 skipped: 1…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added type:bug Bug report: something is not working as documented pkg:healthcheck @connectum/healthcheck (Layer 1) labels Sep 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@connectum/auth

npm i https://pkg.pr.new/@connectum/auth@267

@connectum/cli

npm i https://pkg.pr.new/@connectum/cli@267

@connectum/core

npm i https://pkg.pr.new/@connectum/core@267

@connectum/events

npm i https://pkg.pr.new/@connectum/events@267

@connectum/events-amqp

npm i https://pkg.pr.new/@connectum/events-amqp@267

@connectum/events-kafka

npm i https://pkg.pr.new/@connectum/events-kafka@267

@connectum/events-nats

npm i https://pkg.pr.new/@connectum/events-nats@267

@connectum/events-redis

npm i https://pkg.pr.new/@connectum/events-redis@267

@connectum/healthcheck

npm i https://pkg.pr.new/@connectum/healthcheck@267

@connectum/interceptors

npm i https://pkg.pr.new/@connectum/interceptors@267

@connectum/otel

npm i https://pkg.pr.new/@connectum/otel@267

@connectum/protoc-gen-catalog

npm i https://pkg.pr.new/@connectum/protoc-gen-catalog@267

@connectum/reflection

npm i https://pkg.pr.new/@connectum/reflection@267

@connectum/test-fixtures

npm i https://pkg.pr.new/@connectum/test-fixtures@267

@connectum/testing

npm i https://pkg.pr.new/@connectum/testing@267

commit: 490350f

@intech
intech merged commit d780ab0 into main Sep 28, 2026
40 checks passed
@intech
intech deleted the feat/node24-shutdown-hang branch September 28, 2026 11:17
intech added a commit that referenced this pull request Sep 28, 2026
## Summary

`shutdown.forceCloseOnTimeout` (default `true`) is documented to make
`server.stop()` finish within `shutdown.timeout` "even if clients hold
connections open", but it only destroyed HTTP/2 sessions. The following
cases were measured in Docker on Node 22.13.0, 24.21.0 and 26.10.0 with
a 2 s timeout. In each of them a client kept the server — and the
process — alive **forever**:

- **Default transport** (plaintext, `allowHTTP1: true`), which is
`http.createServer` and therefore HTTP/1.1 only. There are no sessions,
so force-close did nothing against a long, unfinished or idle request.
- **TLS**: a connection that never completed the handshake or sent an
unfinished request.
- **h2c with a client ignoring GOAWAY** on Node 24+. The session is
already destroyed and its half-closed socket waits for the peer's FIN.

Two more gaps:

- **Node 22** `server.close()` sends no GOAWAY (added in Node 24,
nodejs/node#57586), so every idle HTTP/2 client stretched `stop()` to
the full timeout.
- A **TLS session completing its handshake after `close()`** got no
GOAWAY on any version.

What changes in `TransportManager`:

- It tracks every accepted TCP connection (`'connection'`, all modes)
and destroys them on the force-close path. For TLS the raw socket also
covers pre-handshake connections.
- `close()` sends GOAWAY (`session.close()`) to every HTTP/2 session
itself, and closes sessions created while it is closing.
- `forceCloseOnTimeout: false` still destroys nothing. Its JSDoc now
says precisely what happens.
- Connectum never calls `process.exit()`.

Alternatives measured and rejected: `http.Server#closeAllConnections`
(does not exist on `Http2Server`/`Http2SecureServer`), destroying via
`session.socket` (throws on Node 22, no-op on 24/26), `unref`, and
`process.exit`.

## Type of change

- [x] Bug fix (non-breaking)
- [ ] New feature (non-breaking)
- [ ] Breaking change (documented in migration guide)
- [ ] Documentation / chore / internal

Observable change with the default config: connections that outlive the
shutdown timeout are now actually terminated on HTTP/1.1 and TLS, as the
docs promised.

## Test plan

- [x] `pnpm build && pnpm typecheck && pnpm test` pass locally (33/33
turbo tasks)
- [x] `pnpm lint` passes locally. The only warning,
`packages/events/src/broadcast.ts:18`, pre-exists on `main`.
- [x] New
`packages/core/tests/unit/TransportManager.forceClose.test.ts`, 5
scenarios:
- three transports × a raw half-open client that never closes: the
graceful phase keeps it, the force path closes it;
  - an idle HTTP/2 client drains without the force path;
  - a late TLS session receives GOAWAY.

Results: 5/5 under node, bun and esbuild. On Docker `node:22.13.0`
(esbuild) it also passes 5/5. The same test on `main` code gives 1/5.
- [x] The TLS pair is generated per run with `openssl`, so no key
material is committed (the repo ignores `*.key`/`*.crt`).
- [x] Consumer floor: two shutdown checks were added to the release-gate
behavioral smoke. It runs on Node 22.13.0 against the packed artifacts,
as the runtime-matrix spec requires. On `node:22.13.0`, `main` fails
both (`stop()` took 5004 ms; the connection outlived the timeout); with
this PR the smoke is 34/34. Local `pnpm release:gate`: PASS.
- [x] End-to-end: the healthcheck suite with the old non-draining client
helper (the Node 24.21 trigger) completes in 31 s on `node:24.21.0` via
the force path, instead of hanging.
- [x] `test:bun` (14 suites) and `test:esbuild` (226/226) for core.

## Parity coverage

- [ ] Parity coverage added
- [x] Parity N/A: server shutdown and transport connection lifecycle
only. RPC semantics over HTTP and in-process are unchanged, and
`scripts/parity-suite.sh` is unaffected.

## Related issues / changes

- Follows #267, which fixed the Node 24.21 CI hang from the test side;
this PR fixes the framework-side gap.
- Companion docs PR: graceful-shutdown guide (options table, shutdown
sequence, force-close section).
- Out of scope, measured:
- HTTP/1.1 keep-alive idling after the last in-flight response (bounded
by `keepAliveTimeout` or the timeout);
- Bun's `http.Server#close()` callback firing before connections close.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Server shutdown now sends GOAWAY to idle HTTP/2 clients when draining
begins.
* By default, connections still open when the shutdown timeout expires
are closed across HTTP/1.1, HTTP/2, and TLS transports, including
connections with unfinished requests.
* With `forceCloseOnTimeout` set to `false`, shutdown completes after
the timeout without forcibly closing open connections.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg:healthcheck @connectum/healthcheck (Layer 1) type:bug Bug report: something is not working as documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant