Skip to content

fix(lwip2transport): bound half-closed TCP relays - #633

Open
OskarEichler wants to merge 4 commits into
OutlineFoundation:mainfrom
OskarEichler:codex/lwip2transport-half-close-timeout
Open

OskarEichler wants to merge 4 commits into
OutlineFoundation:mainfrom
OskarEichler:codex/lwip2transport-half-close-timeout

Conversation

@OskarEichler

@OskarEichler OskarEichler commented Aug 31, 2026 •

Copy link
Copy Markdown

Summary

  • bound TCP relay half-close handling to 60 seconds
  • fully close both relay connections once copying finishes
  • add a regression test for a peer that never completes its half-close

Problem

When one copy direction reaches EOF, copyOneWay half-closes the destination, but relay waits indefinitely for the opposite direction. If that peer never sends FIN, the application-owned proxy socket can remain in FIN_WAIT_2 indefinitely.

On macOS this reproduced as more than 16,000 FIN_WAIT_2 sockets to one Outline server, exhausting the 16,384-port ephemeral range and causing unrelated connections to fail with EADDRNOTAVAIL.

Approach

After either copy direction completes, set a read deadline on the connection used by the remaining direction. This retains half-close behavior for responses that finish within 60 seconds, uses the existing relay goroutine structure, and guarantees both connections are fully closed afterward.

Verification

  • go test -race ./...
  • go tool staticcheck ./network/lwip2transport

@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown

Greptile Summary

This PR bounds half-closed lwIP TCP relays and ensures both relay connections are released when copying finishes.

  • Adds a 30-second deadline to the remaining read direction after either side finishes sending.
  • Fully closes both connections when the relay exits.
  • Adds a focused test covering cleanup of a stalled half-closed relay.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete correctness or security failures identified.

The deadline is applied to the connection read by the remaining copy direction, and deferred closure releases both relay endpoints after normal completion or timeout.

Important Files Changed

Filename Overview
network/lwip2transport/tcp.go Adds symmetric half-close deadlines and deterministic full connection cleanup without changing the public transport contract.
network/lwip2transport/tcp_test.go Verifies that a stalled half-closed relay terminates promptly and closes each endpoint once.

Sequence Diagram

sequenceDiagram
    participant Client as lwIP connection
    participant Relay as TCP relay
    participant Proxy as Proxy connection
    Client->>Relay: FIN after request
    Relay->>Proxy: CloseWrite
    Relay->>Proxy: SetReadDeadline(now + 30s)
    alt Proxy finishes within timeout
        Proxy-->>Relay: Remaining response + FIN
        Relay-->>Client: Remaining response
    else Proxy remains open
        Proxy--xRelay: Read deadline expires
    end
    Relay->>Client: Close
    Relay->>Proxy: Close
Loading

Reviews (1): Last reviewed commit: "fix(lwip2transport): bound half-closed T..." | Re-trigger Greptile

@fortuna fortuna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for the contributions. I'm sorry for the delay, it's been quite busy.

Comment thread network/lwip2transport/tcp_test.go Outdated
rightConn := &testStreamConn{Conn: right}
start := time.Now()

relayWithHalfCloseTimeout(leftConn, rightConn, 10*time.Millisecond)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please use https://go.dev/blog/synctest for time-sensitive tests.

Comment thread network/lwip2transport/tcp.go Outdated
Comment thread network/lwip2transport/tcp.go Outdated
@OskarEichler

Copy link
Copy Markdown
Author

Addressed all three review requests in 316fefa: the production timeout is now 60 seconds with the FreeBSD FIN_WAIT_2 rationale documented; relay takes the timeout directly, removing the wrapper; and the stalled-half-close regression uses testing/synctest with an exact virtual-time assertion rather than a wall-clock tolerance. Also merged current main. Validation: go test -race ./... passed for the root module, go tool staticcheck ./network/lwip2transport passed, and git diff --check passed. The independent x module was not changed by the fix or tested.

@fortuna fortuna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, but please do this one small fix

Comment thread network/lwip2transport/tcp.go Outdated
// halfCloseTimeout limits how long a peer can leave a relay half-closed.
// Match FreeBSD's default 60-second FIN_WAIT_2 timeout for orphaned sockets:
// https://man.freebsd.org/cgi/man.cgi?query=tcp (fast_finwait2_recycle).
const halfCloseTimeout = 60 * time.Second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm afraid this is too buried here. Let's move this up the stack, into device.go (next to the MTU). Pass it down to newTCPHandler.

I want to avoid the failure mode where we change the implementation (e.g. to gvisor) and forget that this is important.

@OskarEichler

Copy link
Copy Markdown
Author

Addressed the configuration feedback: moved the 60-second half-close timeout next to packetMTU in device.go and pass it into newTCPHandler from both device constructors. The handler now forwards its configured timeout to the relay. Verified with go test -race ./..., targeted staticcheck, and git diff --check.

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.

2 participants