Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions crates/freshell-server/src/net_bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ pub fn bind_reusable(addr: SocketAddr, reuse_port: bool) -> std::io::Result<StdT
Ok(std_listener)
}

/// One live listener: its shutdown signal + the accept-loop task handle. The
/// accept loop drops its listener before exiting, so awaiting the handle is a
/// true "old socket closed" barrier.
/// One live listener: its shutdown signal and accept-loop task handle. The
/// accept loop drops its listener before exiting, so awaiting the handle is
/// a true "old socket closed" barrier. The bound address lives on the
/// controller's `current_addr` mirror (one slot: exactly one listener
/// exists at a time).
struct LiveListener {
shutdown: Arc<Notify>,
accept_loop: JoinHandle<()>,
Expand All @@ -76,6 +78,13 @@ pub struct RebindController {
reuse_port: bool,
app: OnceLock<Router>,
current: Mutex<Option<LiveListener>>,
/// The CURRENT listener's bound address, mirrored for cheap sync reads
/// (NET-02's rollback proof reads product truth instead of probing the
/// kernel namespace, where a sibling's just-assigned wildcard listener
/// makes a plain detector bind lie). Written under the same lock scope
/// as the `current` swap, so it is never newer or staler than the
/// listener itself.
current_addr: std::sync::Mutex<Option<SocketAddr>>,
}

impl RebindController {
Expand All @@ -85,6 +94,7 @@ impl RebindController {
reuse_port,
app: OnceLock::new(),
current: Mutex::new(None),
current_addr: std::sync::Mutex::new(None),
})
}

Expand Down Expand Up @@ -112,6 +122,10 @@ impl RebindController {
let addr = SocketAddr::new(host, self.port);
let std_listener = bind_reusable(addr, self.reuse_port)?; // PROOF: must succeed
let listener = tokio::net::TcpListener::from_std(std_listener)?;
// The listener's OWN address (its port when the caller bound
// kernel-assigned port 0): the product-truth record the rollback
// detector reads.
let bound_addr = listener.local_addr()?;
let shutdown = Arc::new(Notify::new());
let shut = Arc::clone(&shutdown);
let accept_loop = tokio::spawn(async move {
Expand Down Expand Up @@ -157,6 +171,7 @@ impl RebindController {
// this JoinHandle is a true "old listener closed" barrier.
});
let mut cur = self.current.lock().await;
*self.current_addr.lock().expect("current_addr lock") = Some(bound_addr);
if let Some(old) = cur.replace(LiveListener {
shutdown,
accept_loop,
Expand All @@ -167,8 +182,22 @@ impl RebindController {
Ok(())
}

/// The CURRENT listener's bound address, or `None` when nothing is
/// serving. In-memory product truth (never a kernel probe): because
/// [`Self::serve_on`] only records an address AFTER the new bind and
/// only AFTER the previous accept loop's close barrier, this address
/// both proves the recorded listener is live and — the NET-02 rollback
/// use — proves any PREVIOUS listener on another address is gone.
// Consumed by the rollback test's product-truth detector (the `has_app`
// precedent: test-consumed surface until a bin caller reads it).
#[allow(dead_code)]
pub fn current_bind_addr(&self) -> Option<SocketAddr> {
*self.current_addr.lock().expect("current_addr lock")
}

pub async fn shutdown_all(&self) {
if let Some(cur) = self.current.lock().await.take() {
*self.current_addr.lock().expect("current_addr lock") = None;
cur.shutdown.notify_one();
let _ = cur.accept_loop.await;
}
Expand Down
50 changes: 31 additions & 19 deletions crates/freshell-server/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2670,27 +2670,39 @@ mod tests {
}
// Rollback proof: the wildcard listener must be GONE, loopback must
// still serve, and neither BindState nor settings claim 0.0.0.0.
// Wildcard-gone detector: a PLAIN (no SO_REUSEPORT) bind of
// 127.0.0.2:port fails while any 0.0.0.0:port listener survives
// (wildcard conflicts with every specific address; sharing would need
// reuseport on BOTH) and succeeds against the rolled-back 127.0.0.1
// listener (two DIFFERENT specific addresses never conflict).
//
// Both socket-facing detector checks are Env, NOT Product, because
// they have a MEASURED environmental failure mode on this WSL2 host:
// pre-hardening (~1/10 full parallel-suite runs) a detector
// bind/connect on the just-swapped port misbehaved while diagnostics
// confirmed the 500, the rollback, and a truthful 127.0.0.1 BindState
// were all correct. A REAL rollback regression fails them
// deterministically on every fresh-port attempt and so still fails
// the test.
if std::net::TcpListener::bind(("127.0.0.2", port)).is_err() {
return Err(ScenarioError::Env(
"listener left on 0.0.0.0 after failed persist (no rollback), \
or a transient detector-bind artifact"
.into(),
));
// Wildcard-gone detector (NET-02, deflake 2026-09-20): read the
// PRODUCT'S OWN truth — the rebind controller's recorded bound
// address — instead of probing the kernel namespace with a plain
// 127.0.0.2:port bind. `serve_on` records an address only AFTER the
// new bind and only after the previous accept loop's close barrier,
// so `current_bind_addr() == (127.0.0.1, port)` proves the rolled-
// back loopback listener is the one live listener — the wildcard is
// provably closed. The old kernel probe (a plain bind of
// 127.0.0.2:port failing while any 0.0.0.0:port listener survives)
// conflated OUR listener with ANY sibling's: under the full
// parallel suite the kernel reassigned the just-released port to
// other tests' wildcard listeners often enough to burn all five
// retry attempts (the 2026-09-19 base gate, 5/5). Product truth is
// an in-memory read with zero environmental exposure — a REAL
// rollback regression fails it deterministically on every attempt,
// so the check is Product (fail-fast), no longer Env.
let live_addr = state.rebind.current_bind_addr();
if live_addr
!= Some(std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
port,
))
{
return Err(ScenarioError::Product(format!(
"rollback left the rebind controller on {live_addr:?}, not loopback:{port}"
)));
}
// Loopback-serving probe: a real connect against our own just-bound
// listener. Kept Env: the documented WSL2 transient (a detector
// connect on the just-swapped port misbehaving ~1/10 under full
// parallel-suite load) is genuinely environmental, and a REAL
// serving regression still fails every fresh-port attempt.
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_err()
Expand Down
Loading