Skip to content

fix(gateway): close WebSocket clients with 1001 on shutdown - #2553

Merged
enisdenjo merged 2 commits into
graphql-hive:mainfrom
m-sanders:fix/ws-close-1001-on-shutdown
Sep 17, 2026
Merged

enisdenjo merged 2 commits into
graphql-hive:mainfrom
m-sanders:fix/ws-close-1001-on-shutdown

Conversation

@m-sanders

@m-sanders m-sanders commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2546

useServer()'s disposable was discarded and a bare wsServer.close() deferred instead, which closes no clients. Nothing ever told them to go away, so a subscription only ended when server.closeAllConnections() destroyed the socket — which clients see as 1006.

It could also hang. server.close() doesn't call back while a socket is still upgraded, and closeAllConnections() doesn't reach upgraded sockets either, so a live subscription had nothing to end it.

Contract:

  • WebSocket clients are closed with 1001 Going away on SIGINT/SIGTERM.
  • gracefulShutdownTimeout now also bounds the closing handshake. A client that doesn't answer is terminated when the window expires rather than after ws' 30s closeTimeout. With 0, a 1s floor applies so the close frames can flush.
  • No new options. HTTP shutdown behaviour is unchanged.

This doesn't stagger the reconnects — graphql-ws retries 1001 like any other close. That's #2554.

Bun

This does not fix Bun, and cannot: startServerForRuntime picks startBunServer when globalThis.Bun is set, so nodeHttp.ts never runs there. Bun serves WebSockets through Bun.serve and graphql-ws/use/bun, and its disposer calls server.stop() without closing WebSocket clients first. They are dropped rather than closed, so a Bun shutdown still gives clients 1006 - the symptom this PR removes on Node.

The e2e tests that assert 1001 are therefore skipped on the Bun runner. Graceful HTTP shutdown is unaffected and still covered there: should let in-flight requests complete before exiting and should reject healthcheck and readiness probes during drain both pass under Bun. The force-close test also still runs on Bun, since it asserts the process exits rather than a close code.

Happy to follow up with the equivalent for bun.ts if you want it - tracking the open ServerWebSockets and closing them before server.stop() - but it is a separate implementation and I did not want to widen this PR without asking.

@m-sanders
m-sanders force-pushed the fix/ws-close-1001-on-shutdown branch 2 times, most recently from c4bb35d to 8e92eab Compare September 1, 2026 10:55
@m-sanders
m-sanders force-pushed the fix/ws-close-1001-on-shutdown branch from 8e92eab to df6e9fc Compare September 1, 2026 12:19

@enisdenjo enisdenjo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there seems to be some CI tests failing, can you check them out please?

m-sanders and others added 2 commits September 14, 2026 12:01
Use the disposable useServer() returns so clients get a 1001 close frame, drain
the WebSocket server before awaiting the HTTP close, and bound the closing
handshake by gracefulShutdownTimeout so a client that never answers cannot hold
the shutdown open for ws' 30s closeTimeout.
Bun does not close WebSocket clients cleanly on shutdown. Clients see 1006,
which is the symptom this branch fixes on node. Graceful HTTP shutdown does
work under bun - the in-flight-request and readiness-probe tests pass there -
so this is specific to the WebSocket close path.

Bun's `ws` compatibility layer also reports a server-side
close(1001, 'Going away') to the client as code 1000, so the close code could
not be asserted there even once the close frame arrives.

The force-close test keeps running on bun: it asserts the process exits, not a
close code, and that holds on both runtimes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@m-sanders
m-sanders force-pushed the fix/ws-close-1001-on-shutdown branch from df6e9fc to d93020d Compare September 14, 2026 11:59
@m-sanders

Copy link
Copy Markdown
Contributor Author

Thanks — checked, and rebased on latest main. Four failures, one of them mine.

Mine: the two Bun E2E jobs. My graceful-shutdown e2e asserted a close code on the Bun runner. Two independent reasons it cannot pass there, so the WebSocket tests are now skipped on Bun:

  1. Bun does not close WebSocket clients cleanly on shutdown at all — they still see 1006, the symptom this PR removes on Node.
  2. Bun's ws compatibility layer reports a server-side close(1001, 'Going away') to the client as code 1000:
node v22.19.0   server ws close(1001, 'Going away')  ->  client sees { code: 1001, reason: 'Going away' }
bun  1.3.14     same call                            ->  client sees { code: 1000, reason: 'Going away' }

Graceful HTTP shutdown is fine under Bun — should let in-flight requests complete before exiting and should reject healthcheck and readiness probes during drain both pass there, so signals and the disposer stack work. The gap is specific to the WebSocket close path. I've written this into the PR description rather than leaving it implied. The force-close test still runs on Bun, since it asserts the process exits rather than a close code.

Locally: node 20/20 pass, bun 12 passed | 8 skipped, check:types and prettier clean.

Not mine, and left alone:

  • node / auto-type-merging — died in yarn install --immutable before running anything: Error: The onCancel handler was attached after the promise settled at yarn-4.13.0.cjs:141, in hardened mode for public PRs. Looks like a Yarn 4.13.0 flake; the rebase re-runs it.
  • Snapshot — Please add the GITHUB_TOKEN to the changesets action. Fork PRs don't get the secret, so I can't fix it from here.

@m-sanders

Copy link
Copy Markdown
Contributor Author

Correction to my comment above: the close-code detail I gave was wrong, and I've fixed the PR description.

I claimed Bun's ws layer rewrites a server-side close(1001) to 1000. That reproduces only when both ends run under Bun — it is a Bun client artefact. With a Bun server and a Node client, which is the actual e2e topology, the close arrives correctly:

server=bun 1.3.14, client=node v22.19.0  ->  { code: 1001, reason: 'Going away' }

So that was a red herring. The real reason is simpler: startServerForRuntime selects startBunServer when globalThis.Bun is set, so nodeHttp.ts never runs under Bun at all. bun.ts serves WebSockets via Bun.serve + graphql-ws/use/bun, and its disposer calls server.stop() without closing WebSocket clients first — they are dropped, so clients see 1006.

Nothing about the diff changes; the skip is still correct, and for a better reason than the one I gave. Sorry for the noise.

The gap looks straightforward to close in bun.ts — track the open ServerWebSockets and close them with 1001 before server.stop(), with the same fuse. Want that as a follow-up PR, or folded into this one?

@enisdenjo enisdenjo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work, thank you!

@enisdenjo
enisdenjo merged commit c0e1c7d into graphql-hive:main Sep 17, 2026
54 of 55 checks passed
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.

useServer disposable is discarded, so WebSocket subscriptions are never closed gracefully on shutdown

2 participants