Chute Devs

One Connection, Many Decisions — What Survives a Network Change

Hello everyone.

A phone changes networks constantly. Wi-Fi to cellular at the door, a captive portal at the airport, a VPN coming up, a foldable phone opening. iOS tells the engine the path changed. The engine then has to answer a question that sounds simple and is not: which of the things I know are still true?

A connection is not a socket. It is a stack of decisions: the member a policy group chose, the local port a UDP flow owns, the resolver that answered, the pool it was dialled from, the clock a latency probe runs on. A path change invalidates the decisions that were about the path — and only those.

Getting that boundary wrong is not an abstract correctness problem. It is the difference between a video call that survives a Wi-Fi handover and one that hangs for ten minutes; between a policy group that picks one server and one that dials three for a single request; between a UDP game that sees a stable NAT and one that sees a new mapping per destination.

This is what we fixed in the kernel behind Chute iOS 1.3.0 and Chute tvOS 1.5.5.

The Corpse and the Survivor

When iOS reports a path change, the engine deliberately keeps established tunnels. A change on a still-good path must not kill live flows. But when the interface a flow was dialled from is gone, that flow is a corpse: its upstream socket holds a source address that no longer exists, no byte will ever move again, and the app behind it hangs until the idle reaper gets around to it — tens of minutes later.

So the tunnel manager snapshots the addresses the system currently holds and closes the established tunnels whose source address appears in none of them. Pending tunnels are closed too; they have no connection to preserve. The record gets a notice: “Tunnel closed because its source interface vanished after a network change.”

That is the right policy. The bug was in what a QUIC socket answered when asked for its source address.

The QUIC socket forwarded the question to its raw UDP socket, which returned the server’s host and port — the same value it reports as the destination. The orphan pass then looked for the server’s address among the device’s local interfaces, did not find it, and concluded the tunnel was an orphan. Every unpooled Hysteria2, TUIC and MASQUE tunnel, and every chain whose first hop was one of them, was cut on every network change and on every settled path-identity change on iOS.

The older socket transports — WireGuard/Tailscale and the chained transports — had already been fixed for the same shape. QUIC was missed.

The fix is to admit that the answer is unknown. A UDP socket is bound to the wildcard address; asking it for its local endpoint returns 0.0.0.0 or ::, and there is no single local source to compare. The socket now answers “unknown” for its source address and port. The orphan pass treats a source that is not an IP literal, or that equals the socket’s own destination (loopback excepted, where the two rightly match), as unknown — and unknown is not an orphan. A real orphan is still reclaimed; the test that closes one still passes.

The lesson is small and general: “I don’t know” is a valid answer. The bug was inventing an answer from the wrong socket.

The Pool on the Old Path

The XHTTP connection pool had a similar problem, in reverse. Nothing ever reset the pool. Neither a reload nor a network change touched it, so after a switch, HTTP/2 connections established on the old path went on being handed to new sessions. A pooled connection was made for the policy as it was — its REALITY key, its certificate verification — and the policy could have changed since.

Now the pool is retired on every reload and every network change. A session already on a connection keeps it until it lets go; an idle connection closes at once; the next session dials afresh. A retired connection that is still busy takes no new sessions, so it no longer counts against the connection limit — previously it pushed the first session after a switch onto an unpooled connection.

Three leaks fell out of the same review. A session that was closed while waiting for a pooled connection kept its slot for good; it now hands the slot straight back. A connection that never came up kept everyone queued on it forever, because the TLS socket has no handshake deadline; attempts now have a deadline of the dial timeout plus 20 seconds, and their waiters fail with a timeout. A malformed next head behind a completed response was reported only when more bytes arrived; it now fails the connection at once.

A pool is a cache of trust. The trust expires when the path or the policy changes.

The Cache from the Old Network

DNS remembers things to avoid asking twice. Some of that memory is about the network, not the name.

A network change already cleared the last network’s negative answers. It did not clear the two per-family “no address” marks — kept for up to an hour saying “this name has no IPv4” or “no IPv6”. A name the old network had no IPv6 record for was answered “no IPv6” on the new one, for an hour. It also left behind records that had been emptied down to a negative entry and nothing else.

Both marks are now cleared with the rest of what the last path taught, and a record left with no address is removed together with its reverse entries.

The system resolver had a subtler version of the same problem. A profile with no DNS at all, or one that leaves DNS to the system, resolves with the resolver addresses read when the tunnel starts. The network-change reclaim reads them again — but by then the tunnel’s DNS settings are already applied, so the list has collapsed to the loopback address, which the “system” setting has no way to expand back into real servers. After the first network change the pool was empty, and every lookup went through the system’s host resolver (IPv4 and IPv6 only, 60-second answers) until the tunnel restarted. The rebind that follows a network change already takes the settings off for a second; the list is now read in that second, before they go back on.

macOS had the mirror-image bug. With the TUN up and no DNS configured, the system resolver’s list reads as the kernel itself — another answer the “system” setting cannot expand. The pool was empty, the fallback was refused under TUN because it would ask the kernel, and every name failed. That setting now expands, on macOS with the TUN up, to the DNS servers of the primary physical network service, read from the system configuration store: skip tunnel interfaces, skip the kernel’s own range, re-read on every network change.

The old network’s answers are not wrong. They are about a different network.

The Group That Answered Twice

The same question — which decisions belong to the connection? — applies without a network change.

A policy group is supposed to choose a member. On iOS, the tunnel tries up to five resolved addresses at once and uses the first to connect (“optimistic DNS”). The first pass had been changed to take a group’s member once, but every other path still asked the group again: each optimistic candidate ran the second pass and asked for a member of its own. A load-balance or random group dialled several proxies for one connection, and the record kept whichever candidate wrote last. The DNS-failure hand-over judged the final rule’s group by the member it stood for, and then the dial asked the group again.

The tunnel now keeps one member per policy for the life of the connection. The first pass, the resolver’s pool, every candidate, the just-in-time resolve and the DNS-failure branch all use it, so the member judged is the member dialled. Each candidate’s route is kept; the record shows the first one until an adapter wins, then the winner’s. A rule’s notification fires once.

UDP had it worse. Whether to resolve first was judged on the group, and the adapter was taken from the group again — a fresh decision for every datagram. One flow’s datagrams went out through every member. The regression test sent four datagrams and watched them leave through two members, two each. The UDP manager now follows the policy down to the member that actually sends, and uses that member for the direct judgement, the resolver role, the DNS-failure check, the QUIC block, the relay gates, the adapter and the record’s chain.

The semantics differ by group type, on purpose. A load-balance or random group chooses per connection — and now per UDP flow — and keeps that answer while the group still offers the member; an unhealthy load-balance member is replaced. A select, URL-test or fallback group stands for one member and is asked each time, so a switch reaches an existing flow at once. A random group chooses only among members that carry UDP. The built-in proxy policy hands a flow on to the policy it follows.

A load balancer’s job is to choose a server, not to choose one per packet.

The Probe That Was Timed Before It Left

Latency tests had a clock bug that looked like a network bug.

An HTTPS probe that waited for a slot had its clock started at the slot hand-off, then went out after being queued behind every other waiting probe. A WireGuard or Tailscale member resolves its target synchronously on that queue. A probe queued behind such a lookup was timed from a slot it could not use yet: its latency carried the wait, and a pass could judge it out of time before it had sent anything.

The probe now resets its clock to the later of the hand-off time and the moment it actually goes out, right before the tunnel starts; the hand-off’s value stays as the lower bound. The test that exposed it is red on the previous build and green now.

The same review capped concurrency — at most 8 HTTPS probes open at once on iOS/tvOS, 16 on macOS — and made two adjacent guarantees explicit: a dropped round’s waiting probes never go out, and a cancelled probe dials nothing. An HTTPS probe through a member that is itself a group verifies as the member’s node does, and its Host header names the port and brackets an IPv6 literal.

A timeout that starts before the request leaves is not a timeout. It is a guess.

The Mapping That Moved

A SOCKS5 UDP relay used to keep a session per (client, destination) and give each one a synthetic source port of its own. The direct UDP adapter has been full-cone per source endpoint for a while, so the relay’s per-destination sessions undid it: a TUN client got one egress mapping, a SOCKS5 client got a symmetric NAT.

The session is now per client endpoint, whatever destinations it talks to. Its synthetic port is the endpoint’s identity to the tunnel manager, and one egress socket carries all of its destinations. It is released with its association and swept after 60 seconds idle.

Replies had a correctness bug too. A reply’s header named the destination of the flow it came back on, so a datagram from a third party writing to the client’s mapping — or a server answering from another port — was presented as that destination. It now names the datagram’s real source, as RFC 1928 §7 says.

The relay’s own-port loop guard treated a destination written as a host name as a name: a loopback address aimed at the relay’s own port came back in and was relayed again, and an address written as a name went on to the tunnel manager even though the same address written as a literal is dropped. A name is now parsed as an address before anything looks at it, and localhost names are judged as loopback.

One more association bug: a client may send from a port it did not declare, and the admission gate admits it. Relay activity was credited only to associations that declared the sending port (or none), so this client’s association was never credited at all. Its ASSOCIATE control connection closed at its idle deadline mid-transfer, taking the association and its flows with it. A port that no association owns is now attributed to one association and remembered on it (at most 64 ports per association, oldest forgotten first). An older association that never sends is still left to idle out; two mismatched associations of one host each renew only on their own traffic.

A UDP mapping is an identity. If it changes per destination, apps see a symmetric NAT; if it changes per reply, they see the wrong source.

The Boundary

The pattern across all of these is the same. Keep the decisions that belong to the connection. Re-ask the ones that belong to the path.

The member, the UDP mapping, the probe’s clock, the association — identity. The interface, the pool, the negative DNS cache — path. Getting that boundary wrong is how a connection becomes a corpse, a group dials three servers for one request, or a UDP app sees its NAT change every time it talks to a new host.

These changes are in the kernel behind Chute iOS 1.3.0 and Chute tvOS 1.5.5. No configuration changes are needed.

Thanks.

Chute Devs