1. High-Level Summary (TL;DR)
- Impact: [High] — A correctness rewrite of relay teardown on both sides of the tunnel. Every path that could park a relay forever, silently truncate a response, or discard an upload is now bounded and reported. Adds one wire message (
Abort), two client config keys, and retunes every teardown budget. - Key Changes:
- Half-close preserved: The relay no longer tears down the response direction when the upload direction hits EOF. A client that half-closes after sending its request now receives the full reply (previously: 0 of 262144 bytes in the regression test).
- Truncation is no longer silent: A target that dies mid-response is reported as
Message::Errorand the client ends the local socket with a RST instead of a FIN, so the proxied app's read fails rather than returning a short body as success. - New
Message::Abort: Client aborts now release the server's target socket. Measured: a client resetting mid-download made the server pull 179MB over 25s into a connection nobody was listening to; now 1MB over 49ms. - No more parked relays: Bounded every unbounded wait — the client's SSE dispatcher, the server's target-read and target-write tasks, the write-side watchdog, and the abort path's teardown send.
- Client gives up instead of hanging: New
max_reconnect_attempts(default 3) exits non-zero after consecutive reconnects that get no data, so a supervisor can act. Any stream that delivers data resets the count. - Lock-stranding bug fixed: The SSE watchdog could permanently strand a session's mutex, silently freezing a relay mid-connection with nothing logged on either side.
2. Visual Overview (Code & Logic Map)
graph TD
classDef method fill:#bbdefb,color:#0d47a1,stroke:#0d47a1;
classDef action fill:#fff3e0,color:#e65100,stroke:#e65100;
classDef bad fill:#ffcdd2,color:#b71c1c,stroke:#b71c1c;
subgraph "src/relay.rs (client)"
R1["read half: app -> tunnel"]:::method
R2["write half: tunnel -> app"]:::method
R1 -- "app EOF" --> R3["send Close<br/>(half-close only)"]:::action
R1 -- "local failure" --> R4["Abort + detached Close"]:::action
R2 -- "Message::Close" --> R5["shutdown() = FIN"]:::action
R2 -- "Message::Error" --> R6["set_zero_linger + drop = RST"]:::bad
R3 --> J["join!(read, write)"]:::action
R5 --> J
R6 --> J
J --> R7["unregister_connection"]:::action
end
subgraph "src/server.rs"
S1["target read task"]:::method -- "clean EOF" --> S2["Close"]:::action
S1 -- "read error" --> S3["Error + release writer"]:::bad
S4["handle_send"]:::method -- "Abort" --> S5["notify abort<br/>stop reading target"]:::bad
S6["await_sse_dead<br/>(try_lock, 10s)"]:::method -.-> S1
S6 -.-> S7["target write task"]:::method
S2 --> S8["send_to_client<br/>50 x 600ms = 30s"]:::method
S3 --> S8
end
S8 -. "SSE" .-> R2
R4 -. "POST" .-> S4
3. Detailed Change Analysis
Relay Teardown — Client (src/relay.rs)
- What Changed: Upload EOF no longer ends the response direction.
unregister_connectionnow runs only once both halves finish;select!becamejoin!. Previously the read task's EOF dropped the dispatch sender, closedevent_rx, ended the write task, and let the SSE dispatcher discard in-flight target output. Impact: protocols that half-close before the reply (the SOCKS5 test target replies only after seeing the FIN) got a clean-looking EOF on an empty response. - What Changed:
Message::Errorfrom the server is now turned into an abortive close — the socket halves are reunited andset_zero_linger()is applied so the connection ends in RST.Closestill does an explicitshutdown()for a clean FIN. The read half must stop cooperatively via aNotify(aborting the task would drop its socket half, and both halves are required to reunite, silently degrading the abort back to a FIN). - What Changed: The two-half handshake was reworked. Three
Notifyhandles plus anAtomicBooland five task-local flags became two oneshots carrying their reason as an enum (UploadEnd,Stop), with the read loop breaking on anUploadoutcome. The old design rested on two unstated tokioNotifyguarantees. This also closed a case the flags hid:TunnelEvent::Exiton a TCP relay broke the write loop without raising the failure signal, leaving the relay half-dead underjoin!. - What Changed: A local upload failure no longer parks the relay forever. A separate
upload_failedsignal with an ungated branch was added — previously neither a terminal event, a dropped channel, norupload_donecould fire when the tunnel send failed, so the write half sat onrecv()holding the app's socket with no error surfaced. - What Changed: A connection whose response already arrived in full is no longer reset. Once
Closehas been forwarded the app has every byte plus a FIN, and an RST would make its kernel discard unread data — destroying a good response to report a failure on the other direction. Abort is now reserved for genuinely truncated responses. - What Changed: On the abort path the teardown
Closeis handed to a detached task instead of being awaited. It was callingsend_messageon the tunnel that had just broken, spending a fullRECONNECT_WAIT(20s) between the failure and the RST. The read half'sselect!is also biased toward the failure signal, so buffered bytes can't keep winning the race and issuing more sends on a dead tunnel. - What Changed: A dropped dispatch channel now counts as a failure rather than a clean EOF. The invariant is structural: a clean EOF must be earned by an explicit terminal event, and anything else ending the loop is a failure. Two routes previously bypassed this — a session
Resetclearing every channel, and the dispatcher dropping a stalled connection.
Relay Teardown — Server (src/server.rs)
- What Changed: The target writer now survives target-output EOF. It was dropped as soon as the target finished sending, which ended the write task and left
handle_sendwith no writer — so every byte uploaded after that point was silently discarded. A 16 MiB body delivered 0 bytes against a target that replies before consuming its input. The client's ownCloseis now what drops the writer. - What Changed: Added
Message::Aborthandling: a new per-connectionNotifyin the session stops the read task pulling the target, and the writer is released.Closekeeps its narrower meaning ("I am done sending"), so a half-closed app still gets its reply. - What Changed: All four open-coded copies of the retry loop are gone. Terminal messages (
Close,Error,ExitStatus) now route throughsend_to_client, which re-fetchessse_txeach attempt so it follows a client reconnect. A single 500ms attempt against a snapshotted sender lost the message whenever the client's queue was momentarily full or its stream was being replaced — and the client ends its response direction on thatClose, so losing it stranded the relay. - What Changed:
send_to_client_with_attemptsexposes the budget; teardown paths that already know the client is gone spend one best-effort attempt instead of a second full budget.SEND_ATTEMPTSnames the value, which is a documented cross-layer contract with the client'sDISPATCH_STALL_TIMEOUT. - What Changed: Attempts are now paced by wall clock (
SEND_ATTEMPT_INTERVAL). A full channel cost the 500ms send timeout per attempt, but a closed one (client mid-reconnect) failed instantly, so 50 × 100ms was 5s against a stalled client's 30s — tearing down healthy connections across ordinary reconnects. - What Changed:
await_sse_deadusestry_lock, neverlock().await. This was a real freeze: callers hold the future inside aselect!that stops polling it the instant the other branch wins, and tokio's mutex is fair — it hands the freed lock to the head of its queue whether or not anyone is still polling. A watchdog tick queued there stranded the lock, and every latersend_to_clienton that session parked forever. The symptom was a relay going silent mid-connection with nothing logged on either side. A contended tick is now skipped without resettingclosed_secs, so a busy session delays the watchdog but cannot defeat it. - What Changed: The target-read task got the same client-liveness watchdog the write task had. A vanished client plus a silent target left it blocked on
read()forever — delivery retries only notice a dead client when a chunk actually arrives. - What Changed: The TCP relay ran two
await_sse_deadfutures, one per half: two 1s timers and twotry_locks per second on the session mutex. The write side's watchdog is now gated behind a oneshot moved into the read task, so it is parked on a channel until the read task exits (or panics) rather than ticking. - What Changed: A target write failure now releases the writer at the point of failure and reports it. Previously
handle_sendkept accepting client bytes into a channel nobody drained and kept answering 200. - What Changed:
health_checkgained a read timeout — a socket that accepted and then said nothing hung the poll loop rather than failing one attempt. A one-attempt send also no longer pays the retry backoff on its way to giving up. - What Changed: PTY teardown no longer attempts
CloseafterExitStatusexhausted its budget, andforward_pty_chunkwas inlined intosend_to_client.
Client Reconnect (src/tunnel.rs, src/config.rs, src/main.rs)
- What Changed: New
max_reconnect_attempts(config key,--max-reconnect-attemptsflag, default 3). After that many consecutive attempts that get no data from the server, the client logs the reason and exits non-zero instead of retrying forever behind a proxy port that fails every connection. Any stream that delivers data resets the count, so reconnects across a server restart never accumulate.0= retry forever. - What Changed:
reconnect_intervaldefault dropped 5s → 3s. A stream that ends cleanly is now retried after a fixed 1s (CLEAN_END_RETRY) rather than the configured interval — that usually means the server dropped the session and a fresh GET recreates it. - What Changed: An SSE read timeout now backs off by the configured
reconnect_intervalrather than the 1s clean-end path. A 30s silence broke out of the read loop the same way a clean end does, so the outer loop misread a failure as a graceful session drop. The server owes a keepalive every 15s, so two missed in a row is a failure. - What Changed: The SSE dispatcher bounds each delivery. It fed every connection from one loop with an unbounded send, so a local app that stopped reading filled its 256-slot queue and parked the next send — starving every other connection on the tunnel, terminal
Closemessages included. A connection making no progress forDISPATCH_STALL_TIMEOUTis dropped; closing its channel ends its relay and the app sees the failure. One wedged connection instead of a wedged tunnel. - What Changed: A stalled
Closeno longer drops the connection. Everything ahead of it is already queued, so the response is complete and dropping it would reset the app over nothing but the end-of-stream marker. It is handed to a task (bounded byCLOSE_HANDOFF_TIMEOUT) where it starves no other connection; a stalledDatastill drops, since the response is truncated either way.reserve()replacessend()so an event losing its race with the timeout survives the handoff.
Configuration Updates
| Setting | Old | New | Description |
|---|---|---|---|
client.max_reconnect_attempts
| N/A | 3 | Consecutive no-data reconnects before the client exits non-zero. 0 = forever. Restart required.
|
client.reconnect_interval
| 5s | 3s | Wait before reopening the stream after a failure. Restart required. |
SSE_READ_TIMEOUT
| 30s (literal) | 30s (named) | Bytes-on-stream deadline; tolerates one missed 15s keepalive. |
CLEAN_END_RETRY
| N/A | 1s | Delay before reopening a stream that ended cleanly. |
DISPATCH_STALL_TIMEOUT
| N/A | 15s | How long one event may wait for a connection's consumer before it is dropped. Sized at 2× margin under the server's 30s send budget. |
CLOSE_HANDOFF_TIMEOUT
| N/A | 60s | Bound on a Close handed off the dispatch loop.
|
SSE_DEAD_GRACE
| N/A | 10s | Continuous SSE-closed time before a relay gives up on a vanished client. Must clear the client's 3s reconnect sleep plus its GET. |
SEND_ATTEMPTS × SEND_ATTEMPT_INTERVAL
| 50 × 100ms | 50 × 600ms | 30s wall-clock delivery budget, now equal for a full and a closed channel. |
Protocol & Toolchain
- What Changed:
Message::Abort { conn_id }appended last, so existing variant indices are unchanged on the wire. An older server fails to decode it and answers 200 — mixed versions degrade to the previous behaviour rather than breaking. (Source:src/protocol.rs) - What Changed:
rust-version = "1.88"declared (the floor of the locked tree), and the tokio floor raised 1.40 → 1.51 forTcpStream::set_zero_linger. It had been building only becauseCargo.lockhappened to pin 1.51.1.set_zero_linger()replaces the deprecatedset_linger(Some(ZERO)). (Source:Cargo.toml) - What Changed:
tokiowithtest-utiladded as a dev-dependency sostart_paused = truetests run instantly; dev-only, so the test clock never ships in a release build.
Tests
- What Changed: Two new integration suites —
tests/relay_half_close.rs(451 lines: half-close through SOCKS5, 16 MiB upload past target-output EOF, client abort releasing the target with a real RST) andtests/reconnect_gives_up.rs(320 lines: give-up budget, budget reset on real data). Both give-up assertions require exit code 1 specifically, so a panic or signal kill no longer satisfies them. - What Changed: Unit coverage added for both timeout mirrors —
a_reconnect_inside_the_grace_does_not_tear_downanda_slow_consumer_inside_the_stall_timeout_is_not_dropped— which are what make the bounds limits on wedged consumers rather than penalties on slow ones. Every new test was verified to fail with its fix removed. - What Changed: The older integration tests no longer read the developer's environment. Both failures reproduce on
main: the server falls back to./config.tomlthen~/.config/tunnix/config.tomlwhen no--configis given (a developer withallow_transfer = truethere breakstest_transfer_denied_when_disabled), and spawned children inherit*_PROXY, which the HTTP client honours even though everything here talks over loopback. Each spawned process now gets an empty config in the test's own directory, andno_inherited_proxymoved totests/common/mod.rsso a new proxy variable needs one edit rather than three. - What Changed:
socks5_connectgained a write timeout. It set only a read timeout, sowrite_allcould block forever against a relay that stopped draining without closing — andcargobounds neither a test nor the suite, so that is a hung CI job rather than a red build.
4. Impact & Risk Assessment
- Breaking Changes: None on the wire in the compatible direction — a new client against an old server degrades to the previous behaviour (the old server rejects
Abortand keeps draining the target). An old client against a new server is unaffected; it simply never sendsAbort. - Behavioural change to watch: the client now exits after 3 consecutive no-data reconnects where it previously retried forever. Anything running
tunnix clientwithout a supervisor will stop instead of hanging. Setmax_reconnect_attempts = 0to restore the old behaviour. - Behavioural change to watch: a truncated response now reaches the proxied app as a connection reset rather than a clean EOF. That is the point of the change — trusting EOF as end-of-payload was silent data corruption — but an app that treated short reads as success will now surface an error it previously swallowed.
- Known gaps, documented not fixed:
handle_send's send to the target writer is the last unbounded wait on a hot path. It is genuine backpressure and does resolve when the target dies; bounding it means choosing between dropping bytes and killing slow-but-healthy transfers — a policy call.DISPATCH_STALL_TIMEOUTis a stopgap, not backpressure. An app that pauses that long with a full queue (~8MB buffered) has its connection reset where TCP would have made the target wait. Real per-connection backpressure needs flow control in the protocol.- Nothing clears a session's writers when its SSE stream ends, so if target output ends first and the client process dies without sending
Close, the writer entry lingers for the life of the session. - The late-
Error-after-Closeordering has no test: asserting it means catching an abort on the app's write side after its read has legitimately seen EOF, which on loopback is inherently racy. That fix is reasoned, not measured. - The initial connection is still not retried at all — if the server is unreachable at startup the health check fails and the client exits immediately.