Repository navigation
Fix nine gaps the Rust port's reviews found in the Go client and server - #10
Conversation
Each was found and fixed in getlantern/soar-rs#1 first; the Go code had the same bug. Client: - Close sent RESET once the send buffer was empty, but that means the data was acked, not the FIN; the server aborted the stream and lost the request. RESET now waits for the FIN's ack. - Discovery counted every path ever verified toward Want, and the last usable path is never suspended, so a blocked resolver kept discovery stopped. Only healthy paths count (usable, no run of losses). - Discovery adds one candidate per pass, but the run loop only woke on its one-second default; it now wakes when the pace allows the next. - An answer stopped the liveness clock even with other queries out; if every path then died, the session was never presumed lost. - Hello retries always revisited the first 16 resolvers; they now go to the least recently tried first. - The tail copy of an upload could exceed MaxInflight. - newTxnLocked retried random IDs with no exit: MaxInflight is capped at 1024, and nothing is sent once 60,000 queries are pending. Session and server: - A full control queue silently dropped frames callers had already counted as sent (a dropped MAX_STREAM_DATA stalls a stream for good). Overflow now ends the session, and exact duplicates are skipped; the stream-slot accounting is guarded so an overflow can't release a slot twice. - A malformed destination prefix ended in a graceful close, which a client waiting for OPENED can't read as a refusal; it's now refused with RESET_STREAM code 1. - PublicOnly rejects 192.88.99.0/24, 2001:20::/28 and 3fff::/20. Each fix but the liveness one has a test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
…r the lock Codex gate: the run loop could add a candidate before the test took c.mu, so the discovery tests could fail on an unlucky schedule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
|
Warning Review limit reachedOnly developers with an assigned seat can use this organization's usage-based review budget, and seats here are assigned manually. Ask an admin to assign a seat, or change the review continuation mode in Billing. Next included review available in 42 minutes. View limit details
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
🟡 Changes recommended
The fixed queue limit can terminate valid high-concurrency sessions, and critical recovery branches lack regression coverage.
3 open findings
What changed in this PR
Ports reliability and security fixes discovered during the Rust implementation back into the Go client and server.
Changes:
- Corrects stream closure, control-queue, and malformed-destination handling.
- Improves discovery, liveness, retry rotation, and query limits.
- Expands reserved-address filtering and regression coverage.
| File | Description |
|---|---|
stream.go |
Waits for FIN acknowledgement before reset. |
session.go |
Handles control-queue overflow and slot accounting. |
server.go |
Refuses malformed destinations and reserved ranges. |
e2e_test.go |
Adds server and stream regressions. |
discover.go |
Counts only healthy discovery paths. |
client.go |
Improves scheduling, retries, liveness, and limits. |
client_gaps_test.go |
Adds client regression tests. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
From Copilot's review: - MaxStreams had no upper bound, and every new local stream queues a window grant, so a burst of legitimate streams could overflow the control queue and end a healthy session. MaxStreams is now capped at 256 for client and server: at most three control frames per stream fits the 1,024-frame queue, so only a misbehaving peer overflows it. - TestLivenessClockRestartsWithQueriesOut: an authenticated answer with another query out keeps the clock running, and the session is presumed lost after deadSessionAfter. - TestPeerStreamReturnsReservedSlotOnOverflow: when advertising a new peer stream overflows the queue, the reserved slot comes back once. Each test fails with its fix disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
…althy paths From Copilot's review: the caps were silently applied, and Discovery.Want was still documented as counting verified paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
…healthy paths Matching the Go port (getlantern/soar#10), where Copilot found the caps undocumented and Discovery.Want still described as counting verified paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
The hello loop and the unverified-path loop only checked MaxInflight, so a client already near maxPending could push past it. sendDataLocked now returns before building a packet when the ceiling is reached, and both loops stop at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
The sendDataLocked guard matters only for the tail copy, since fill and the unverified-path loop check the ceiling themselves. The new test starts one below it and fails without the guard. Mirrors soar-rs#2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
…a running one The run loop refilled after the answer and its new query restarted the clock, so the test passed on some runs with the fix removed. Paths are now suspended once the queries are out, which is the case the fix is for. The clock's start is also moved 5 s back, so the test checks it restarted at the answer rather than kept running from the first query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5


Why
The reviews of the Rust port (getlantern/soar-rs#1), by Codex, Copilot, CodeRabbit and an automated security review, turned up bugs that the Go client and server share. Each was fixed and tested in Rust first. This PR ports the fixes.
Client
Conn.Closesent RESET as soon as the send buffer was empty. But an empty buffer means the data was acknowledged, not the FIN, so the server aborted the stream before dialing and the request was lost. RESET now waits for the FIN's ack.Want. The client never suspends its last usable path, so a blocked resolver kept counting. Now only healthy paths count: verified, usable, and not on a run of losses.MaxInflight. It now checks the cap.newTxnLockedcould spin forever. It retried random IDs with no exit, so if every DNS ID was taken it looped holding the client lock.MaxInflightis now capped at 1,024, and nothing is sent once 60,000 queries are pending. Every send path checks that ceiling: hellos, probes of unverified paths, fill and the tail copy.Session and server
MAX_STREAM_DATAstalls a stream for good. Overflow now ends the session (only a misbehaving peer can fill the queue), and exact duplicates are skipped. In Rust, this exact change caused a double release of the cross-session stream slot, which the security review caught, so the Go version includes that guard:maybeRemovegives back a slot only if the stream is still there, andpeerStreamreturns its reserved slot if advertising ended the session.MaxStreamshad no ceiling. A stream can queue up to three control frames, so a largeMaxStreamscould let legitimate traffic fill the control queue, which now ends the session. It's capped at 256 (a quarter of the queue) on both client and server.waitOpenedhanging. It ended in a graceful close, which a client waiting for OPENED can't tell from success. It's now refused withRESET_STREAMcode 1.PublicOnlyaccepted three reserved ranges:192.88.99.0/24(6to4 relay anycast),2001:20::/28(ORCHIDv2) and3fff::/20(documentation).Testing
go test -race ./...passes.rawSessionnow also returns the downstream cipher, soTestMalformedDestinationIsRefusedcan read the server's frames.TestIdleSessionGoesQuiet(e2e_test.go:1150). It didn't reproduce: 15 isolated runs and 3 full runs each passed on this branch and onmain. I'm treating it as a rare flake, not caused by this PR, but noting it in case it recurs.Pre-PR review (local Codex gate)
CODEX GATE: verdict=approve critical=0 important=0 minor=0 reviewed=12approve. Both rounds read every changed file.client_gaps_test.go:40: the discovery tests raced the client's run loop, which could add a candidate before the test took the lock. The tests now build the client with discovery off and enable it under the lock.🤖 Generated with Claude Code
https://claude.ai/code/session_01QQFpfCXs3JqrfyDuiGabF5
Summary by CodeRabbit