Skip to content

Fix WebSocket normal close status handling - #102

Merged
samchon merged 5 commits into
masterfrom
fix/websocket-close-status
Sep 22, 2026
Merged

samchon merged 5 commits into
masterfrom
fix/websocket-close-status

Conversation

@samchon

@samchon samchon commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Problem

WebSocketAcceptor compared close code 100 instead of RFC 6455 normal closure code 1000. In addition, omitting a close code passed undefined to Node's ws implementation, which sent an empty close frame (dropping any reason), exposed 1005 to the peer, and made browser and Node behavior diverge.

WebSocketServer.close() also terminated every client socket abruptly, so remote connectors observed 1006 instead of a proper going-away signal.

Changes

  • Centralize the RFC 6455 close codes (1000 normal closure, 1001 going away) in internal/WebSocketCloseCode.ts.
  • Treat 1000 as a normal close on both acceptors and connectors.
  • Normalize omitted close and reject codes to 1000.
  • Keep non-1000 codes as WebSocketError statuses.
  • WebSocketServer.close() now sends 1001 ("WebSocketServer is going away.") to every client and waits for each closing handshake before shutting the server down.
  • Document the close-code behavior (including the browser-side restriction to 1000 / 3000–4999) and add regression coverage for both directions, default closes, explicit normal closes, abnormal closes, default rejection, and server shutdown.

Behavior changes

  • acceptor.close() / connector.close() without a code: in-flight RFCs now reject with the generic Error("Connection has been closed.") instead of WebSocketError(1005).
  • acceptor.reject() without a status: connect() throws WebSocketError(1000, reason) instead of WebSocketError(1005, ""); the reason is now delivered.
  • server.close(): connectors receive WebSocketError(1001, "WebSocketServer is going away.") instead of WebSocketError(1006). Shutdown waits for the close handshake, so an unresponsive peer can delay it by up to ws's 30-second close timeout.

Toolchain

This branch also carries the TypeScript 7 / ttsx / pnpm migration commits (a910a2e, 384afd3, 94a00dc). The fix commit itself applies cleanly to master without them.

Closes #99.

Validation

  • pnpm build
  • pnpm test
  • pnpm exec tsc --noEmit --project test/tsconfig.json
  • Confirmed test_web_close_status fails against the pre-fix source and passes with the fix.

🤖 Generated with Claude Code

@socket-security

socket-security Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​ttsc/​paths@​0.30.4761008696100
Addednpm/​rollup-plugin-esbuild@​6.2.19910010080100
Addednpm/​@​ttsc/​unplugin@​0.30.48210010096100
Addednpm/​ttsc@​0.30.48810010096100
Updatednpm/​typescript@​5.4.5 ⏵ 7.0.29910089 -1100100 +10

View full report

Use RFC 6455 normal closure status 1000 consistently across WebSocket acceptors and connectors. Normalize omitted close codes and add regression coverage for normal, abnormal, and rejected connections.
@samchon
samchon force-pushed the fix/websocket-close-status branch from 0fb6fe5 to bbf412e Compare September 22, 2026 08:07
Replace the literal readyState 3 with client.CLOSED in WebSocketServer
shutdown, document that server shutdown sends 1001 and waits for each
closing handshake, and note the browser-side close code restriction on
IWebSocketCommunicator.close().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@samchon samchon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review

Verdict: the core fix is correct and the regression test is real. Approving after two small follow-ups pushed in fd29137 (details below).

Verified

  • test_web_close_status passes on this branch and fails against the pre-fix source (Client normal close(default) was treated as an error.), so it genuinely covers #99.
  • Full pnpm run test:node passes; CI (NodeJS + Browser) is green.
  • The fix commit applies cleanly onto current master without the toolchain commits.

Core fix

Change Assessment
_Handle_close: code !== 100 → !== NORMAL_CLOSURE Obvious typo fix, exactly #99.
socket.close(code ?? NORMAL_CLOSURE, reason) Correct. ws's Sender.close sends an empty frame when code === undefined and silently drops reason; the peer then sees 1005. Normalizing to 1000 unifies Node and browser behavior and makes reason actually reach the peer.
Connector !event.code || event.code !== 1000 → !== NORMAL_CLOSURE Equivalent simplification.

Impact scope for the record: destructor(error) only uses error to reject in-flight RFC promises; join() is unaffected. So the bug was "pending RPCs during a normal close got WebSocketError(1005) instead of Error("Connection has been closed.")" — narrow, but worth fixing.

Findings

  1. PR description was missing the amended WebSocketServer._Close() change — the 0fb6fe5 → bbf412e amend added graceful shutdown with 1001, GOING_AWAY, and test_server_shutdown, none of which were in the body. That is an observable behavior change (terminate() → clients saw 1006; now close(1001, …) → clients see 1001). Fixed: PR body updated with a "Behavior changes" section.

  2. server.close() can now block up to 30 s — the graceful path waits for each client's closing handshake, and ws only destroys the socket after closeTimeout = 30 * 1000. Previously terminate() was immediate. This is consistent with acceptor.close() / connector.close(), which already have the same exposure, so I left the behavior as designed and documented it on WebSocketServer.close(). If shutdown latency under zombie connections matters (e.g. k8s grace periods), a bounded grace period followed by terminate() would be a reasonable follow-up.

  3. reject() defaulting to 1000 — RFC 6455 defines 1000 as "purpose fulfilled", which reads oddly for a rejection; 1008 (Policy Violation) would be more idiomatic. Functionally identical (_Handshake's onclose throws for any code), and it is strictly better than the old default (1005, reason lost), so leaving it. Noted in the body so consumers checking error.status know.

  4. Scope — the PR carries three toolchain commits (TS 7 / ttsx / pnpm, +5.9k-line lockfile, CI). Unrelated to #99 and the fix is independent of them. Not blocking since CI validates them, but noted in the body.

  5. Nits (fixed in fd29137)

    • client.readyState === 3 → client.CLOSED.
    • IWebSocketCommunicator.close() JSDoc now notes that browsers only allow 1000 or 3000–4999 from the client side (InvalidAccessError otherwise). Pre-existing: connector.close(1008) in a browser throws after state_ = CLOSING is already set, leaving the connector stuck — worth a follow-up guard.
  6. Follow-up, not in this PR — WebSocketAcceptor.upgrade() still calls socket.close() without a code on malformed headers (:100, :108), so the peer sees 1005. The existing @todo covers it; 1003 / 1007 would fit.

🤖 Generated with Claude Code

@samchon
samchon merged commit 940fd82 into master Sep 22, 2026
4 checks passed
@samchon
samchon deleted the fix/websocket-close-status branch September 22, 2026 08:42
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.

The status code is incorrectly specified

1 participant