Skip to content

fix: keep a read limit set before NetConn - #586

Draft
mafredri wants to merge 10 commits into
masterfrom
mafredri/netconn-keep-read-limit
Draft

mafredri wants to merge 10 commits into
masterfrom
mafredri/netconn-keep-read-limit

Conversation

@mafredri

@mafredri mafredri commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

NetConn called SetReadLimit(-1) every time, so the outcome depended on call order: a limit set after NetConn applied, and a limit set before it was dropped without an error. Code that set a limit and then called NetConn had no limit at all.

NetConn now disables the limit only if SetReadLimit was never called on the Conn. A limit set before or after NetConn is kept, and so is an explicit SetReadLimit(-1). Callers that never call SetReadLimit see no change: NetConn still disables the 32768 byte default.

Behavior change: if you call SetReadLimit(n) before NetConn, that limit is now enforced. Reading a message larger than n bytes fails with ErrMessageTooBig, as SetReadLimit documents; before this change the message was read in full. To keep the old behavior, remove the SetReadLimit call or pass -1.

New tests cover a limit set before NetConn, set after it, never set, and set to -1 before it, on native and on Wasm in the browser. The before-NetConn test fails on master on both platforms. ./ci/lint.sh, go test -race ./... and the Wasm browser test pass.

Refs #244 (this PR does not add the ReadLimit option field).

Decision log
  • Keep a caller's limit instead of only documenting that SetReadLimit must follow NetConn: documentation alone leaves the call-order problem for every caller.
  • No ReadLimit field in AcceptOptions/DialOptions here: that is a separate API decision on Add ReadLimit to Option structs #244, and the field would need this rule anyway, because NetConn would otherwise discard it.
  • A sync.Mutex on both Conn types closes a race in which NetConn disabled a limit that SetReadLimit set at the same time. It is never held during I/O. A compare-and-swap on the limit without a sentinel value still lost the limit in a stress test, and a sentinel value would need handling in limitReader.reset and the Wasm read path.

🤖 This PR was created with the help of Coder Agents.

@mafredri
mafredri force-pushed the mafredri/netconn-keep-read-limit branch from cb29345 to ef9faa0 Compare October 30, 2026 15:18
NetConn always called SetReadLimit(-1), so a limit set before NetConn
was silently dropped while one set after it applied. NetConn now
disables the limit only if SetReadLimit was never called on the Conn.
The Wasm Conn tracks SetReadLimit calls separately from the native Conn,
so the same four cases run in the browser against the echo server.
The fix-close-race-net-errclosed branch adds a test at the same place in
ws_js_test.go, and the two insertions conflict on merge.
@mafredri
mafredri force-pushed the mafredri/netconn-keep-read-limit branch from ef9faa0 to 125d60d Compare November 3, 2026 19:14
@mafredri

This comment has been minimized.

@coder-agents-review

coder-agents-review Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Chat: Review posted | View chat
Requested: 2026-11-03 21:05 UTC by @mafredri
Spend: $12.78 / $100.00

Review history
  • R1 (2026-11-03): 9 reviewers, 1 Nit, 2 P3, 3 P4, COMMENT. Review
  • R2 (2026-11-03): 8 reviewers, 1 Nit, 2 P3, 7 P4, COMMENT. Review

deep-review v0.13.0 | Round 2 | e1a21e5..bc04f4b

Last posted: Round 2, 10 findings (2 P3, 7 P4, 1 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P4 Author fixed (598dd5b, ea90a58) conn_test.go:351 readLimitDisabledBeforeNetConn passes on base and duplicates netConn/readLimit R1 Netero Yes
CRF-2 Note Note (body) netconn_js_test.go:52 Wasm over-limit cases pass only because browser rejects close code 1009 R1 Netero No
CRF-3 OOS Out of scope (body) ws_js.go:149 Wasm Close(StatusMessageTooBig) rejected by browser; conn stays open, contradicts SetReadLimit doc R1 Netero No
CRF-4 P3 Author fixed (69b18fe) netconn.go:50 Check-then-set on readLimitSet lets a concurrent SetReadLimit be overwritten by -1 R1 Bisky P4, Gon P3, Takumi Note Yes
CRF-5 P4 Author fixed (cb74ece) netconn.go:47 NetConn doc does not say the kept limit is per WebSocket message R1 Leorio P4, Gon P2 Yes
CRF-6 P3 Author fixed (69b18fe, bc04f4b) conn.go:62 readLimitSet undocumented on both Conn types; Wasm field sits under msgReadLimit comment R1 Gon P3, Razor Nit Yes
CRF-7 P4 Author fixed (01daeff, 32c8a81) netconn_js_test.go:27 Test message sizes are unnamed literals tied to the default and the 1024 limit R1 Gon P3, Gon P4 Yes
CRF-8 Nit Author fixed (ea90a58) conn_test.go:321 setLimit field name and subtest names differ from the Wasm test R1 Gon Yes
CRF-9 P4 Dropped by orchestrator (native never-set case is covered by existing netConn/readLimit, so the description's coverage claim holds) PR description Description lists a new native never-set test that the diff does not add R1 Leorio No
CRF-10 Note Note (body) netconn.go:50 coder/coder WebsocketNetConn and agentsdk 4 MiB limits become enforced after upgrade R1 Ryosuke, Pariston, Mafuuu, Razor No
CRF-11 OOS Out of scope (body) coder/coder agent/x/agentdesktop/api.go:122 SetReadLimit(-1) then WebsocketNetConn now yields a 4 MiB limit, comment says no limit R1 Pariston, Razor No
CRF-12 OOS Out of scope (body) read.go:532 Native error reports limit plus one; Wasm reports the set limit R1 Leorio No
CRF-13 OOS Out of scope (body) ws_js.go:87 Wasm default limit literal duplicates defaultReadLimit R1 Gon No
CRF-14 P4 Open netconn.go:52 No test runs SetReadLimit and NetConn concurrently; removing readLimitMu passes all tests, though -race would catch it R2 Bisky, Takumi Yes
CRF-15 P4 Open netconn.go:52 NetConn uses private lock fields of two build-tagged Conn types; protocol split across three files R2 Meruem Yes
CRF-16 P4 Open read.go:104 setReadLimit does not say callers must hold readLimitMu and that it leaves readLimitSet unchanged R2 Gon Yes
CRF-17 P4 Open conn_test.go:471 Native TestNetConnReadLimit has no Unset case and does not point to TestConn/netConn/readLimit R2 Leorio Yes
CRF-18 OOS Out of scope (body) main_test.go:23 TestMain goroutine count races CloseRead goroutine exit; new test copies the CloseRead pattern R2 Ging-go No

Contested and acknowledged

Round log

Round 1

Round type: round 1, full panel. Netero first pass: 1 P4, 1 Note, 1 OOS; no P0, panel proceeded. Law not run (effective additions 141). Panel: ging-go, ryosuke, takumi (specialists), pariston, mafuuu (core), bisky (tests changed), gon, leorio (floor), razor (wildcard, random). Cross-check: race finding merged from Bisky, Gon, Takumi at P3 (higher severity wins; decision log accepts the gap without a ticket). Gon P2 on doc wording merged into Leorio P4 doc finding: keep-argument was that redundant doc text costs readers, but the consequence is wording only and Leorio's suggested text already adopts Gon's one-sentence rule. Gon ws_js.go:49 P3 and Razor Nit merged into CRF-6 at P3. Gon netconn_js_test.go:27 P3 downgraded to P4 and merged with Gon conn_test.go:324 P4: keep-argument was that a default change makes two cases vacuous; the 32768 default is documented public behavior, so a change to it is a reviewed API change. CRF-9 dropped: keep-argument was that a reader looks for a test that does not exist; the claim is about coverage and the existing native test covers it. Reviewed against e1a21e5..125d60d.

Round 2

Churn guard PROCEED: CRF-1, 4, 5, 6, 7, 8 author fixed. Round type: fix round (no files added or renamed, no exported signature change, effective additions +63; native tests were moved into TestNetConnReadLimit in response to CRF-8). Netero: no findings, two notes. Panel: bisky, gon, takumi, leorio, razor (raised findings whose code changed), ging-go, ryosuke (Go specialists), meruem (wildcard, random). Fixes verified by Razor, Ryosuke, Leorio, Gon, Netero. CRF-14: Bisky and Takumi both P4; Meruem's note that a race test cannot reliably trigger is contradicted by Bisky and Takumi, who saw -race report DATA RACE after deleting the lock; ci/test.sh runs go test -race. CRF-15 and CRF-16 interact: moving the lock protocol into a per-platform method keeps every setReadLimit caller in the file that declares the lock. CRF-17 kept at P4: keep-argument is that a maintainer comparing the platform tables concludes native never-set has no test; drop-argument (author replied on CRF-8) is not visible in the code. CRF-18 OOS: orchestrator reproduced 2/60 isolated -race runs of TestNetConnReadLimit and 4/60 of base TestConn/ReadLimitExceededReturnsErrMessageTooBig failing with goroutine leak, so the root cause predates the PR. Full-suite -race runs under load also hung in TestWriteSingleFrameCompressed (compress_test.go:121) and failed TestConn/pingReceivedPongNotReceived; both unrelated to the diff, not recorded as findings. Reviewed against e1a21e5..bc04f4b.

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 NetConn keep a read limit set with SetReadLimit before it is called, instead of always disabling it. Findings: 2 P3, 3 P4, 1 Nit.

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

  • ws_js.go:149: on Wasm, exceeding the read limit calls Close(StatusMessageTooBig, ...), which the browser rejects for code 1009, so the connection stays open, contrary to the SetReadLimit doc.
  • read.go:532: the native error and close reason report the internal limit plus one (read limited at 1025 bytes after SetReadLimit(1024)), while Wasm reports 1024, so the two platforms name different limits.
  • ws_js.go:87: the Wasm default limit is the literal 32768, duplicating defaultReadLimit in read.go, so the two defaults can diverge without a compile error.
  • coder/coder agent/x/agentdesktop/api.go:122: calls SetReadLimit(-1) with the comment "No read limit", then codersdk.WebsocketNetConn, which sets 1 << 22 before NetConn. With this PR the desktop connection is limited to 4 MiB per message and the comment becomes false.

Notes:

  • coder/coder (on v1.8.14) calls SetReadLimit(1 << 22) before NetConn in codersdk/websocket.go:46 (WebsocketNetConn, about 19 callers) and codersdk/agentsdk/agentsdk.go:413. Those 4 MiB limits have never been enforced and will be after the upgrade. No current path was found that sends a single message over 4 MiB, but the text-mode JSON paths were not checked, so the release notes should name this change.
  • The Wasm BeforeNetConn and AfterNetConn cases pass only because the browser rejects close(1009) (first out-of-scope item), so the deferred Close(StatusNormalClosure, "") still reaches the echo server. If that close starts succeeding, TestWasm fails with no change to the code under test.

🤖 This review was automatically generated with Coder Agents.

Comment thread netconn.go Resolved
Comment thread conn.go Resolved
Comment thread netconn.go Resolved
Comment thread conn_test.go Resolved
Comment thread netconn_js_test.go Resolved
Comment thread conn_test.go Resolved
A net.Conn caller reads a byte stream and cannot tell that a kept read
limit counts each WebSocket message separately. Before the read limit
was kept, NetConn disabled it, so the doc did not need to say this.
The standalone test repeated netConn/readLimit with one added
SetReadLimit(-1) call. As a table case with an expected result, it sits
next to the cases it contrasts with, matching the Wasm test.
Each message size must exceed a limit defined elsewhere, and a bare
literal hides which one. Naming the sizes by the limit they exceed shows
which cases stop testing anything if a default or limit changes.
The native closure was named setLimit although it also creates the
net.Conn, and the native cases ran as TestConn subtests under different
names than the Wasm cases. A top-level TestNetConnReadLimit with a
netConn field matches TestWasmNetConnReadLimit case for case.
The cases that expect NetConn to read without error only test something
while their message exceeds the default read limit. A control case that
reads the same size without NetConn fails if the default is raised to
that size, instead of letting those cases pass without a limit to
disable.
NetConn checked readLimitSet and then disabled the limit, so a
SetReadLimit that ran between the two steps was overwritten and left the
connection without a read limit. A mutex around both operations makes
the check and the update atomic.
The field comment described only readLimitMu. A reader also needs to
know that only SetReadLimit sets readLimitSet, because NetConn disables
a limit set through setReadLimit alone.
@mafredri

mafredri commented Nov 3, 2026

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.

This PR makes NetConn keep a read limit set with SetReadLimit before it is called. Findings this round: 4 P4.

All six round 1 findings are fixed and verified at bc04f4b: the readLimitMu lock closes the concurrent SetReadLimit/NetConn race, the field and NetConn docs are in place, and the test tables, constants, and names match on both platforms. The round 1 out-of-scope items are unchanged.

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

  • main_test.go:23: TestMain counts goroutines right after m.Run with no retry, but CloseNow can return before the CloseRead goroutine (read.go:77-84) exits, so a run can fail with "goroutine leak detected". Under -race, isolated runs failed 4 of 60 times for the existing TestConn/ReadLimitExceededReturnsErrMessageTooBig on base and 2 of 60 times for the new TestNetConnReadLimit, which uses the same CloseRead pattern.

🤖 This review was automatically generated with Coder Agents.

Comment thread netconn.go
// Reading a message that exceeds it returns an error wrapping ErrMessageTooBig.
func NetConn(ctx context.Context, c *Conn, msgType MessageType) net.Conn {
c.SetReadLimit(-1)
c.readLimitMu.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-14] No test calls SetReadLimit and NetConn concurrently, so deleting the readLimitMu lock passes every test in the PR. (Bisky, Takumi)

The reply on the concurrent-overwrite thread says such a test would fail too rarely to catch a regression, but ci/test.sh runs go test -race, and the race detector reports the unsynchronized readLimitSet access whenever the two calls run without ordering, not only when the limit is lost. A test that runs c1.SetReadLimit(readLimit) in an xsync.Go goroutine while the test goroutine calls NetConn on a fresh Conn passed 5 of 5 -race runs at HEAD and reported WARNING: DATA RACE in 3 of 3 runs with the Lock/Unlock lines removed. Add it as a Concurrent case in TestNetConnReadLimit.

🤖

Comment thread netconn.go
// Reading a message that exceeds it returns an error wrapping ErrMessageTooBig.
func NetConn(ctx context.Context, c *Conn, msgType MessageType) net.Conn {
c.SetReadLimit(-1)
c.readLimitMu.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-15] NetConn now locks readLimitMu, reads readLimitSet, and calls setReadLimit, which are private fields and methods that conn.go and ws_js.go must each declare with the same names. (Meruem)

Before this PR, netconn.go (built on both platforms) used only Conn methods. Now the lock protocol is split across netconn.go, read.go, and ws_js.go, so a change to the limit state on one platform must also change netconn.go, and a native build does not show whether the Wasm side still compiles. Move these lines into a method such as func (c *Conn) disableReadLimitIfUnset() next to SetReadLimit in read.go and in ws_js.go, and call it from NetConn.

🤖

Comment thread read.go
c.setReadLimit(n)
}

func (c *Conn) setReadLimit(n int64) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-16] setReadLimit has no comment saying that callers must hold readLimitMu and that it does not set readLimitSet. (Gon)

In this package lowercase twins such as ping/Ping and write/Write do the same work as the exported method, so a reader expects setReadLimit(n) to behave like SetReadLimit(n). A future internal caller, such as the ReadLimit option discussed on #244, that calls setReadLimit(n) would have n replaced with -1 by the next NetConn, which is the bug this PR fixes. Add // setReadLimit stores n without setting readLimitSet. Callers must hold readLimitMu. here and on the Wasm setReadLimit (ws_js.go:429); if the lock protocol moves into a per-platform method as suggested on the NetConn lock, the comment still applies.

🤖

Comment thread conn_test.go
<-writeDone
})

for _, tc := range []struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-17] The native TestNetConnReadLimit table has no Unset case, and nothing in the test says that TestConn/netConn/readLimit (conn_test.go:288) covers it. (Leorio)

TestWasmNetConnReadLimit runs Unset, DisabledBeforeNetConn, BeforeNetConn, and AfterNetConn, so a maintainer comparing the two tests will conclude that the native never-set case is untested. Add one line above the table, for example // TestConn/netConn/readLimit covers the case where SetReadLimit is never called.

🤖

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