Skip to content

fix: send a close frame for over-long reasons and invalid codes - #583

Draft
mafredri wants to merge 19 commits into
masterfrom
mafredri/close-reason-truncate
Draft

mafredri wants to merge 19 commits into
masterfrom
mafredri/close-reason-truncate

Conversation

@mafredri

@mafredri mafredri commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Close now sends a valid close frame for any code and reason. Since v1.8.11 (db18a31), a reason over 123 bytes or an invalid status code made Close return an error without writing one, so the peer received no close code (browsers report 1006).

  • A reason over 123 bytes is cut without splitting a UTF-8 character. The code is kept. If the close has no other error, Close returns an error wrapping the new ErrCloseReasonTruncated.
  • An invalid code is sent as 1011 with no reason, and Close returns an error.
  • Invalid UTF-8 in the reason is replaced with U+FFFD, since RFC 6455 requires a UTF-8 reason.

On WASM, Close handles the reason the same way; there, the browser's close() would otherwise throw on a long reason and leave the connection open. The native Close doc now states that the connection is closed when the first Close returns, even if it returns an error.

The new connection tests fail on master and pass here, and the WASM tests pass in headless Chrome.

Decision log
  • Chosen: truncate and return an error, so the peer receives the code and a prefix of the reason.
  • Rejected: sending 1011 with no reason (v1.8.10), which replaces a valid code; sending an empty reason, which sends none of the reason; truncating without an error, so Close returns nil for a shortened reason.
  • Replacing invalid UTF-8 returns no error: the replaced bytes are not valid UTF-8, so no characters are removed.
  • A close failure takes precedence over the truncation and invalid-code errors, so errors.Is(err, ErrCloseReasonTruncated) means Close had no other error to report. With both errors joined, a caller that skips ErrCloseReasonTruncated would also skip a failed close.

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

Since db18a31, writeClose returned the marshal error before writing, so
Close with an invalid code or reason sent no close frame and the peer
saw an abnormal closure. Close now sends StatusInternalError, completes
the handshake and still returns the error, as v1.8.7 to v1.8.10 did.
A reason over the 123 bytes a close frame can carry made Close replace
the caller's code with StatusInternalError and drop the reason, so the
peer lost both. Close now sends the code with the longest prefix that
does not split a UTF-8 rune and reports ErrCloseReasonTruncated.
The browser's close() throws for a reason over 123 UTF-8 bytes before
changing any state, so Close returned an error and left the connection
open. Close now truncates the reason as the native implementation does
and records the reason it actually sent.
The previous wording promised that Close always closes the connection,
but a concurrent Close can return after waitGoroutines times out while
the first call is still blocked in the handshake. Only the first call is
guaranteed to return with the connection closed.
ErrCloseReasonTruncated is reported even when writing the close frame
fails, so its doc and the "sent N of M bytes" detail could be false.
Both now describe only the shortening of the reason.
Go strings become JavaScript strings with invalid bytes replaced by
U+FFFD, so a 50 byte reason of invalid UTF-8 already exceeds the
browser's 123 byte limit. Truncation only guarantees a fit for valid
UTF-8, and the library does not validate reasons.
U+FFFD decodes as utf8.RuneError with size 3, so a check on the rune
alone would split it. Close must also report the transport error
alongside ErrCloseReasonTruncated when the close frame write fails.
RFC 6455 defines the close reason as UTF-8, and peers that check it fail
the connection instead of reading the code. On js/wasm, invalid bytes
also grow when converted to a JavaScript string, so the browser threw
for reasons that fit. Truncation now applies to the replaced reason,
which can be longer than the caller's.
The caveat that invalid UTF-8 can still make the browser reject the
reason no longer holds, since Close now replaces it before truncating.
The doc now matches the native Close.
The error said "from" the caller's length, but replacing invalid UTF-8
can grow a reason, so the message could read "truncated from 122 to 123
bytes". Both numbers now describe the reason that was cut, so "from" is
always larger than "to".
Close joined the truncation error first, so the message led with how the
reason changed and the failure itself followed on the next line. On
WASM, a close() that throws also dropped the truncation error the Close
doc promises.
errors.go holds the exported errors. The helper and its limit move to
close_shared.go, which has no build tag because both the native Close in
close.go (!js) and the WASM Close in ws_js.go call it.
master defines maxCloseReason as maxControlPayload - 2, but the shared
helper needs it on js, where frame.go is not built, so it became a
literal 123. maxControlPayload moves to close_shared.go so both builds
can derive the limit from it.
The unexported helpers and the tests restated what the Close doc, the
code or the test names already say, and the RFC note in wireCloseReason
was hard to follow. Keep each fact in the exported docs and only the
comments that explain something the code does not show.
@mafredri

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Chat: Review posted | View chat
Requested: 2026-10-23 19:21 UTC by @mafredri

Review history
  • R1 (2026-10-23): 10 reviewers, 7 Nit, 8 P3, 2 P4, COMMENT. Review
  • R2 (2026-10-23): 10 reviewers, 8 Nit, 11 P3, 5 P4, COMMENT. Review

deep-review v0.13.0 | Round 2 | 325114f..b8f74f0

Last posted: Round 2, 24 findings (11 P3, 5 P4, 8 Nit), COMMENT. Review

Finding inventory

Finding inventory: PR #583

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author contested; panel closed R2 (1/1 accept) close.go:182 writeClose normalization (peer echo, writeError paths) has no test R1 Netero Yes
CRF-2 P3 Author fixed (b8f74f0) close.go:304 Invalid-code error says "failed to marshal close frame" but a 1011 frame is sent R1 Netero Yes
CRF-3 Note Dropped by orchestrator (merged into CRF-9 as the structural fix) close.go:310 bytes() relies on caller normalization; Close path normalizes twice R1 Netero No
CRF-4 Note Dropped by orchestrator (no change requested; cost negligible per Netero) close_shared.go:17 ToValidUTF8 scan on every close reason vs repo stance on UTF-8 checks R1 Netero No
CRF-5 P3 Author fixed (b8f74f0, documented) close.go:99 Close doc says invalid codes send 1011, but 1005 sends an empty frame, drops reason, returns nil R1 Mafuuu P3, Gon P3, Meruem P3, Leorio P3, Ryosuke P4, Melody P4 Yes
CRF-6 P3 Author fixed (b8f74f0) close.go:99 Close doc omits that an invalid code drops the reason and which codes are invalid R1 Leorio P3, Mafuuu P4 Yes
CRF-7 P3 Author fixed (b8f74f0) close.go:122 errors.Join makes ErrCloseReasonTruncated match when the close also failed (native and WASM) R1 Ryosuke P3, Leorio P3 Yes
CRF-8 P3 Author fixed (b8f74f0) close.go:122 Truncation-only result is prefixed "failed to close WebSocket" though the close succeeded (native and WASM) R1 Leorio Yes
CRF-9 P3 Author fixed (0b09d99, a747f21, documented) close.go:299 wireCloseError/wireCloseReason return a usable value with a non-nil error, undocumented; writeClose discards it with ce, _ R1 Gon P3, Netero Note Yes
CRF-10 P3 Author fixed (b8f74f0) conn_test.go:121 closeReasonTruncatedWithCloseRead reaches the net.ErrClosed branch in about 90% of runs R1 Bisky Yes
CRF-11 P4 Author fixed (b8f74f0) conn_test.go:144 HasSuffix assertion pins join order and prints only true/false on failure R1 Bisky Yes
CRF-12 P4 Author fixed (a747f21) close_shared.go:25 Truncation error "from" length exceeds the caller's length after UTF-8 growth, with no cause stated R1 Leorio Yes
CRF-13 Nit Author fixed (0b09d99) ws_js_test.go:68 Comment describes the JS expansion as current behavior R1 Leorio Yes
CRF-14 Nit Author fixed (b8f74f0) conn_test.go:112 Use errors.AsType (go 1.26) instead of var + errors.As R1 Ging-Go Yes
CRF-15 Nit Author fixed (0b09d99) close_test.go:101 truncated string field holds the expected error suffix; conn_test.go uses truncated bool R1 Gon Yes
CRF-16 Nit Author fixed (b8f74f0) conn_test.go:90 Test case ascii named for its alphabet, not the boundary R1 Gon Yes
CRF-17 Nit Author fixed (b8f74f0) ws_js.go:232 WASM Close doc restates the reason contract in different words and omits "without splitting a rune" R1 Gon Yes
CRF-18 Nit Author contested; panel closed R2 (1/1 accept) close_shared.go:1 _shared suffix is new to the repo and the file now holds maxControlPayload R1 Gon Yes
CRF-19 Nit Author fixed (a747f21) close_shared.go:13 maxCloseReason comment does not start with the name (AGENTS.md:111) R1 Gon Yes
CRF-20 Note Dropped by orchestrator (verification note, no change requested) close.go:115 No new locks or goroutines; close tests pass 50x under -race R1 Takumi No
CRF-21 Note Dropped by orchestrator (verification note, no change requested) close_shared.go:16 WASM byte count matches native from Go to the browser R1 Melody No
CRF-22 Note Dropped by orchestrator (verification note, no change requested) close_test.go:93 Reason-handling tests assert peer-side results R1 Bisky No
CRF-23 Note Dropped by orchestrator (verification note, no change requested) ws_js.go:253 Browser-throw comment states the consequence R1 Leorio No
CRF-24 OOS Out of scope (body) ws_js.go:268 WASM Close passes invalid codes and CloseNow's 1001 to the browser, which throws and leaves the connection open R1 Netero, Melody, Pariston No
CRF-25 OOS Out of scope (body) read.go:271 parseClosePayload accepts invalid UTF-8 peer reasons that RFC 6455 says must fail the connection R1 Netero No
CRF-26 OOS Out of scope (body) close.go:92 "Additional calls to Close are no-ops" is inaccurate; a second call returns net.ErrClosed R1 Gon No
CRF-27 OOS Out of scope (body) close.go:63 CloseError/CloseStatus docs and code use pre-1.26 errors.As; tc := tc copies remain in tests R1 Ging-Go No
CRF-28 P3 Open close.go:190 "Close returns this error to its caller." is false; writeError and the echo drop the error, and Close's own call returns it R2 Gon P3, Leorio P4, Melody P4, Mafuuu P4, Zoro P4, Ryosuke Nit, Meruem Nit Yes
CRF-29 P3 Open close.go:308 wireCloseError doc says the result "can always be sent" but a 1005 result must be sent with no payload; second sentence restates the body R2 Gon P2 Yes
CRF-30 Nit Open close.go:105 "A non-nil error reports a problem during the close." adds nothing to the previous sentence R2 Gon P2 Yes
CRF-31 P3 Open close.go:113 Close drops the invalid-code error when the handshake also fails, losing the only report of a caller bug R2 Leorio Yes
CRF-32 P4 Open close.go:115 Decision log says ErrCloseReasonTruncated means the close completed, but Close returns it alone when the peer was already gone R2 Mafuuu Yes
CRF-33 P4 Open conn_test.go:158 writeErrorReasonTruncated (and badClose, closeReason, closeReasonTruncatedWithCloseRead) discard the peer's read error, so failures print a zero value R2 Melody P4, Leorio Nit Yes
CRF-34 P4 Open conn_test.go:123 closeReasonTruncatedWithCloseRead no longer guards anything other tests miss R2 Bisky Yes
CRF-35 P4 Dropped by orchestrator (raiser closed CRF-1; the test fails when writeClose normalization is reverted, 20/20 runs) conn_test.go:148 writeErrorReasonTruncated uses a reason length no production writeError caller passes R2 Bisky No
CRF-36 Note Note (body) ci/test.sh:5 CI test job failed on b8f74f0; not reproduced locally; log needs authenticated access R2 Netero, Melody, Mafuuu, Meruem, Takumi No
CRF-37 OOS Out of scope (body) close.go:128 exportedClose maps every net.ErrClosed to nil, so Close returns nil when no close frame was sent R2 Mafuuu No
CRF-38 Note Dropped by orchestrator (verification notes, no change requested) close.go:99 CRF-2, CRF-5, CRF-6, CRF-7, CRF-8, CRF-12, CRF-14 fixes verified R2 Leorio, Melody, Mafuuu, Meruem, Bisky, Ging-Go, Ryosuke, Netero No

Contested and acknowledged

CRF-1 (P3, close.go:182) - writeClose normalization has no test

  • Finding: The wireCloseError call in writeClose changes behavior only for the peer echo (read.go:360) and writeError (write.go:429), and reverting it passed the suite; proposed tests for the echoed invalid-UTF-8 reason and for writeError with a reason over 123 bytes.
  • Author defense: Added writeErrorReasonTruncated (b8f74f0) via an export_test.go WriteError helper. Declined the echo test because RFC 6455 section 8.1 says an invalid UTF-8 peer reason should fail the connection, so a U+FFFD echo test would pin behavior that a fix to read.go:271 (CRF-25) removes.
  • Panel closure (R2, 1/1): Netero, the agent that raised it, mutated writeClose to build a raw CloseError and writeErrorReasonTruncated failed 3/3; accepted that the untested echo path is the one CRF-25 would change. Bisky (not a raiser) noted that no production writeError caller passes a reason over 123 bytes, so the test input is synthetic; recorded as CRF-35 and dropped because the test still catches a revert.

CRF-18 (Nit, close_shared.go:1) - _shared file suffix

  • Finding: The _shared suffix is new to the repository and the file also holds maxControlPayload; suggested close_reason.go.
  • Author defense: The PR owner chose the name; close.go is !js, so code both builds call needs an untagged file, and close_reason.go names only part of its contents. A native/WASM file restructure is planned separately (no ticket).
  • Panel closure (R2, 1/1): Gon, the agent that raised it, accepted: the file must be untagged and holds more than the reason helper, and grep maxControlPayload finds the constant in one step. Acceptance does not depend on the planned restructure.

Round log

Round 1

Full panel (round 1). Netero first pass: 2 P3, 2 Notes, no P0, panel proceeded. Law not run (effective additions 243). Panel: ging-go, ryosuke, takumi, melody (domain specialists: Go, Go API, close/shutdown lifecycle, wire format), pariston, mafuuu (core), bisky (tests changed), gon, leorio (once-per-PR floor), meruem (wildcard, random draw). Size 9 + wildcard exceeds the focused target of 6-8 because specialists, core, Bisky, and the Gon/Leorio floor are each required. Result: 9 P3, 2 P4, 7 Nit posted; 6 Notes dropped (4 verification notes, CRF-3 merged into CRF-9, CRF-4 no action); 4 out of scope. Pariston and Takumi: no findings. Reviewed against 325114f..7daec2c.

Round 2

Fix round (commits 0b09d99, a747f21, b8f74f0 fix round 1 findings; no files added or renamed, no exported signature change; native Close body moved into exportedClose to fix CRF-7/CRF-8). Churn guard PROCEED: 16 addressed, 2 contested (CRF-1, CRF-18). Netero (reused agent, dispute check-in) closed CRF-1, no new findings. Panel: gon (reused agent, dispute check-in for CRF-18), leorio, ryosuke, melody, mafuuu, meruem, bisky, ging-go (raisers of findings whose code changed; Gon/Leorio mandatory because comments changed), takumi (Close restructure), zoro (wildcard, random draw). New: 3 P3, 3 P4, 1 Nit posted; CRF-29 downgraded P2 to P3 (keep-argument: a new bytes caller following the doc would send 1005 on the wire; downgraded because the function is unexported with one guarded caller), CRF-30 downgraded P2 to Nit (keep-argument: exported doc text; downgraded because it adds no false claim, only redundancy). CI test job failed on b8f74f0; cause not found locally. Reviewed against 325114f..b8f74f0.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR makes Close always send a close frame by normalizing the code and reason before the frame is built, on native and WASM. 9 P3, 2 P4, 7 Nit.

Out of scope (needs a ticket or explicit acceptance by a human):

  • ws_js.go:268: WASM Close passes the code to the browser unchanged, including CloseNow's 1001 (ws_js.go:248); the WHATWG close() throws for codes other than 1000 and 3000-4999, so Close returns an error and the connection stays open after setCloseErr recorded a sent close (inferred from the spec, not run in a browser).
  • read.go:271: parseClosePayload accepts a peer reason with invalid UTF-8, which RFC 6455 sections 5.5.1 and 8.1 say must fail the connection; Read returns the raw bytes to the caller.
  • close.go:92: "Additional calls to Close are no-ops" is inaccurate; a second call returns failed to close WebSocket: use of closed network connection.
  • close.go:63: the CloseError and CloseStatus docs and code use the var ce; errors.As pattern, and tests keep tc := tc copies, although go.mod declares go 1.26.

🤖 This review was automatically generated with Coder Agents.

Comment thread close.go
Comment thread close.go Outdated
Comment thread close.go Outdated
Comment thread close.go Outdated
Comment thread close.go Outdated
Comment thread close_test.go Outdated
Comment thread conn_test.go Outdated
Comment thread ws_js.go Outdated
Comment thread close_shared.go
Comment thread close_shared.go Outdated
The previous cleanup also removed comments that state facts the code
does not show: why writeClose ignores the wireCloseError error, why
wireErr is joined after the net.ErrClosed check and in that order, what
wireCloseError guarantees, and why the CloseRead test exists. The
truncated test field is renamed to wantErrSuffix because it holds the
expected end of the error message.
Replacing invalid UTF-8 with U+FFFD can make a reason longer, so a
122-byte reason could report "truncated from 244 to 123 bytes". The
error now ends with "after replacing invalid UTF-8" when the replaced
length differs from the caller's.
Close joined the truncation error with any close failure and prefixed it
with "failed to close WebSocket", so errors.Is could not tell a
completed close with a shortened reason from a failed close. Close now
returns the close failure if there is one, and otherwise the unprefixed
wire error, so a match on ErrCloseReasonTruncated means the close
completed. The invalid code error states that 1011 was sent, the Close
doc lists valid codes and StatusNoStatusRcvd, and a test covers a long
reason passed to writeError.
@mafredri

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Round 2 of the close-frame fix. 3 P3, 3 P4, 1 Nit, all new. The round 1 fixes hold, except that the comment added on the discarded writeClose error is false (reopened on that thread). The two contested items (no test for the peer echo, the close_shared.go name) are accepted.

The new findings are about the round 2 restructure of Close: the invalid-code error is now dropped when the close also fails, and the new writeClose and wireCloseError comments describe behavior the code does not have.

The CI test job failed on b8f74f0 after 2m36s with exit code 2; round 1's head and the base passed. The failure did not reproduce locally: go test -race ./..., the ci/test.sh command, CI=1 runs that include TestWasm in headless Chrome, and hundreds of runs of the new subtests all passed. The job log needs authenticated access. Read it or rerun the job; if a new subtest failed, that is a finding against this PR.

Out of scope (needs a ticket or explicit acceptance by a human):

  • close.go:128: exportedClose maps every net.ErrClosed to nil, so Close returns nil when the connection was already gone and no close frame was sent.

🤖 This review was automatically generated with Coder Agents.

Comment thread close.go Outdated
Comment thread close.go Outdated
Comment thread close.go
Comment thread close.go
Comment thread conn_test.go Outdated
Comment thread conn_test.go Outdated
bytes encoded StatusNoStatusRcvd as a 1005 status code, which peers
reject, and only a check in writeClose kept it off the wire, so the
wireCloseError doc was wrong. bytes now returns no payload for it, and
the writeClose comment states which callers drop the wire error. Tests
cover Close with StatusNoStatusRcvd and print the peer's error when no
close frame arrives; the CloseRead subtest is removed because other
tests cover the net.ErrClosed path.
"A non-nil error reports a problem during the close" repeats the line
before it, and a truncation-only error is not a problem during the
close.
@mafredri

Copy link
Copy Markdown
Member Author

In reply to CRF-28 (P3, close.go:190): #583 (review)

Agreed, the comment was wrong. ec299b6 replaces it: Close reports the error from its own wireCloseError call, and writeError and the reply to a peer's close frame drop it.

🤖 Posted using /amend-review skill via Coder Agents.

This branch has not been deployed

No deployments
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