github aeroxy/tunnix 0.5.0
tunnix v0.5.0

4 hours ago

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::Error and 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
Loading

3. Detailed Change Analysis

Relay Teardown — Client (src/relay.rs)

  • What Changed: Upload EOF no longer ends the response direction. unregister_connection now runs only once both halves finish; select! became join!. Previously the read task's EOF dropped the dispatch sender, closed event_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::Error from the server is now turned into an abortive close — the socket halves are reunited and set_zero_linger() is applied so the connection ends in RST. Close still does an explicit shutdown() for a clean FIN. The read half must stop cooperatively via a Notify (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 Notify handles plus an AtomicBool and five task-local flags became two oneshots carrying their reason as an enum (UploadEnd, Stop), with the read loop breaking on an Upload outcome. The old design rested on two unstated tokio Notify guarantees. This also closed a case the flags hid: TunnelEvent::Exit on a TCP relay broke the write loop without raising the failure signal, leaving the relay half-dead under join!.
  • What Changed: A local upload failure no longer parks the relay forever. A separate upload_failed signal with an ungated branch was added — previously neither a terminal event, a dropped channel, nor upload_done could fire when the tunnel send failed, so the write half sat on recv() holding the app's socket with no error surfaced.
  • What Changed: A connection whose response already arrived in full is no longer reset. Once Close has 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 Close is handed to a detached task instead of being awaited. It was calling send_message on the tunnel that had just broken, spending a full RECONNECT_WAIT (20s) between the failure and the RST. The read half's select! 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 Reset clearing 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_send with 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 own Close is now what drops the writer.
  • What Changed: Added Message::Abort handling: a new per-connection Notify in the session stops the read task pulling the target, and the writer is released. Close keeps 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 through send_to_client, which re-fetches sse_tx each 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 that Close, so losing it stranded the relay.
  • What Changed: send_to_client_with_attempts exposes the budget; teardown paths that already know the client is gone spend one best-effort attempt instead of a second full budget. SEND_ATTEMPTS names the value, which is a documented cross-layer contract with the client's DISPATCH_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_dead uses try_lock, never lock().await. This was a real freeze: callers hold the future inside a select! 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 later send_to_client on 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 resetting closed_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_dead futures, one per half: two 1s timers and two try_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_send kept accepting client bytes into a channel nobody drained and kept answering 200.
  • What Changed: health_check gained 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 Close after ExitStatus exhausted its budget, and forward_pty_chunk was inlined into send_to_client.

Client Reconnect (src/tunnel.rs, src/config.rs, src/main.rs)

  • What Changed: New max_reconnect_attempts (config key, --max-reconnect-attempts flag, 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_interval default 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_interval rather 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 Close messages included. A connection making no progress for DISPATCH_STALL_TIMEOUT is 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 Close no 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 by CLOSE_HANDOFF_TIMEOUT) where it starves no other connection; a stalled Data still drops, since the response is truncated either way. reserve() replaces send() 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 for TcpStream::set_zero_linger. It had been building only because Cargo.lock happened to pin 1.51.1. set_zero_linger() replaces the deprecated set_linger(Some(ZERO)). (Source: Cargo.toml)
  • What Changed: tokio with test-util added as a dev-dependency so start_paused = true tests 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) and tests/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 mirrorsa_reconnect_inside_the_grace_does_not_tear_down and a_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.toml then ~/.config/tunnix/config.toml when no --config is given (a developer with allow_transfer = true there breaks test_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, and no_inherited_proxy moved to tests/common/mod.rs so a new proxy variable needs one edit rather than three.
  • What Changed: socks5_connect gained a write timeout. It set only a read timeout, so write_all could block forever against a relay that stopped draining without closing — and cargo bounds 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 Abort and keeps draining the target). An old client against a new server is unaffected; it simply never sends Abort.
  • Behavioural change to watch: the client now exits after 3 consecutive no-data reconnects where it previously retried forever. Anything running tunnix client without a supervisor will stop instead of hanging. Set max_reconnect_attempts = 0 to 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_TIMEOUT is 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-Close ordering 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.

Don't miss a new tunnix release

NewReleases is sending notifications on new releases.