← LOGBOOK LOG-457
WORKING · SOFTWARE ·
RUSTHANDOFFTCPNETWORKINGSOCKETSTESTINGOWNERSHIPLAN

HandOff: TCP Reachability and a LAN Listener

HandOff verifies a loopback target with TcpStream and reserves an operating-system-selected LAN port by retaining ownership of a TcpListener.

HandOff needs two sockets with opposite directions. A TcpStream connects outward to the developer’s existing application; a TcpListener waits for connections arriving from other devices. These endpoints exist in the core, but no byte or HTTP forwarding connects them yet.

developer application                 HandOff listener
127.0.0.1:3000                        0.0.0.0:<automatic port>
        ↑                                      ↑
  outgoing check                       incoming connections

The target address identifies one local application. The listener address reserves a new socket on the machine. The eventual proxy must accept a stream from the listener, open a stream to the target, and transfer protocol data in both directions.

Loopback and the Target Application

The CLI port refers to a service bound on IPv4 loopback:

127.0.0.1:<target port>

Loopback traffic remains inside the host. A development server listening only on 127.0.0.1:3000 cannot ordinarily be reached by a phone using the Mac’s LAN address. HandOff exists to create a separate LAN-facing endpoint while leaving that application configuration unchanged.

The reachability operation belongs to handoff-core:

use std::net::TcpStream;

pub fn is_reachable(port: u16) -> bool {
    TcpStream::connect(("127.0.0.1", port)).is_ok()
}

TcpStream::connect asks the operating system to establish a TCP connection. Its result has two forms:

Ok(TcpStream)   → a TCP peer accepted the connection
Err(io::Error)  → the connection could not be established

.is_ok() discards the stream and the specific error, reducing the result to the boolean the current CLI needs. The successful TcpStream is dropped immediately after the check, which closes that probe connection.

The check has a deliberately narrow meaning. It establishes that some process accepted TCP connections on that loopback port at that moment. It does not establish that the process speaks HTTP, belongs to the intended application, or will remain available after the check. Those distinctions matter once the proxy starts forwarding requests.

The LAN-Facing Socket

The second operation creates an incoming listener:

use std::io;
use std::net::TcpListener;

pub fn bind_listener() -> io::Result<TcpListener> {
    TcpListener::bind(("0.0.0.0", 0))
}

The address has two independent wildcard choices:

0.0.0.0 → bind on all local IPv4 interfaces
port 0  → ask the operating system for an available port

Binding 127.0.0.1 would create another loopback-only service and would not solve LAN access. Binding 0.0.0.0 includes loopback and active IPv4 network interfaces. A peer does not connect to the literal address 0.0.0.0; HandOff must later discover the Mac’s usable LAN address, such as 192.168.1.42, and combine it with the selected port.

The actual bound address is available from the listener:

let listener = bind_listener()?;
let address = listener.local_addr()?;
let public_port = address.port();

Port zero is an instruction during bind, not the final port. After a successful bind, local_addr() reports the concrete port assigned by the operating system.

The Listener Is the Reservation

bind_listener returns io::Result<TcpListener> rather than only io::Result<u16>. The distinction preserves the resource that makes the port usable.

bind succeeds
  → TcpListener owns an operating-system socket
  → socket keeps the selected port reserved
  → dropping TcpListener closes the socket
  → operating system may reuse the port

Returning only the number would require dropping the listener inside the function. Another process could bind that number before HandOff tried to recreate the listener, producing a time-of-check/time-of-use race. Returning the owned socket makes the reservation and its lifetime the same value.

This is ownership applied to an operating-system resource. TcpListener is not merely configuration describing a port; it is a live handle. Rust’s Drop behavior closes the underlying socket when the last owning value leaves scope.

io::Result<TcpListener> keeps binding failure explicit. The port may be unavailable, the address family may be unsupported, or the operating system may reject the operation. A caller cannot use the listener without first handling Err.

Core Ownership of Networking

Both functions remain in handoff-core:

pub fn is_reachable(port: u16) -> bool
pub fn bind_listener() -> io::Result<TcpListener>

The CLI currently imports only is_reachable. bind_listener exists but is not connected to the command flow. This is incomplete integration, not redundant code: it establishes the next resource boundary before proxy behavior is added.

current
target port → validate → connect probe → print result

next
target port → validate → connect probe → bind listener
            → accept incoming stream → connect target stream
            → forward HTTP request and response

Keeping listener creation in the core means a CLI and GPUI client receive the same binding behavior. The interfaces can choose how to display the selected address and how to initiate shutdown without duplicating socket policy.

Testing Reachability with a Real Socket

The reachability test creates a listener before probing it:

#[test]
fn detects_a_listening_port() {
    let listener =
        TcpListener::bind(("127.0.0.1", 0))
            .expect("failed to create test listener");

    let port = listener
        .local_addr()
        .expect("listener has no local address")
        .port();

    assert!(is_reachable(port));
}

The test does not assume that port 3000 or any other fixed port is free. It asks the operating system for one, reads the assigned number, and probes it while the test-owned listener remains alive. is_reachable needs only the completed TCP handshake; the test does not need to call accept.

The ordering is the invariant:

bind listener → read assigned port → connect while listener lives

Moving the connection attempt after the listener leaves scope would close the socket and reverse the expected result.

Testing Automatic Public-Port Selection

The listener test checks that port zero is replaced with a concrete port:

#[test]
fn binds_to_an_available_port() {
    let listener = bind_listener().expect("failed to bind listener");

    let address = listener
        .local_addr()
        .expect("listener has no local address");

    assert_ne!(address.port(), 0);
}

.expect converts an unexpected setup failure into a test failure with local context. assert_ne! checks the externally visible postcondition without depending on which port the operating system chose.

Together with the four CLI parser tests, the workspace now has six tests:

handoff-cli
├── accepts 3000
├── rejects 0
├── rejects nonnumeric text
└── rejects 65536

handoff-core
├── detects a listening target
└── binds an automatically selected public port

The tests cover validation and socket setup, not the product success criterion. No test yet connects through the HandOff listener and observes a response from the target application because the accept loop and proxy do not exist.

The Remaining Network Path

The current listener makes the machine reachable on a selected port, but it does not accept or serve connections. Completing the path requires several additional invariants:

  • The listener must remain owned for the entire sharing session.
  • Each incoming connection must be associated with a new connection to 127.0.0.1:<target>.
  • HTTP methods, paths, headers, bodies, status codes, and response bodies must cross the boundary correctly.
  • WebSocket upgrades require bidirectional long-lived forwarding rather than one request followed by one response.
  • Shutdown must stop accepting work and close active resources coherently.
  • The displayed URL must use a real LAN address, not 0.0.0.0.

Binding to all interfaces also changes the security boundary immediately. Once an accept loop serves data, devices capable of reaching the Mac on that network may reach the shared application. The UI must describe that exposure before convenience obscures it.

The present code establishes the two endpoints and their ownership. The proxy is the controlled connection between them; it should be added without collapsing target validation, listener lifetime, protocol forwarding, and interface output into one function.

Sources