feat(router): parallel candidate route-group setup (steady-connection fix) - #4078
Merged
Merged
Conversation
… fix) Route-group setup previously walked candidate routes SEQUENTIALLY: the policy picked ONE route, saveRouteGroupRules set it up with one handshake plus the #4057 retransmit over handshakeAwaitTimeout (~10s), and only on failure did a bounded retry loop query a fresh route and try the next candidate. Each dead / non-forwarding candidate therefore cost up to the full ~10s handshake await, and a run of bad candidates burned the ~90s dial ceiling -> "Route group setup canceled ... context deadline exceeded" and the app wedged in "starting" (observed live: skysocks-client -> Frankfurt at min_hops>=2). DialRoutes now sets up the top-K candidates CONCURRENTLY and the FIRST to complete its reciprocal handshake WINS and becomes the working base (the dial returns on it immediately; mux legs grow on top later and never unseat it). Losing / failed candidates are canceled and torn down. This collapses establishment from "up to N x handshakeAwaitTimeout" to ~one RTT and makes a dead-hop candidate lose the race instead of blocking, so the app converges to a working route (single multihop at min_hops>=2, or direct at min_hops==1) instead of wedging. min-hops-agnostic. Data-model note: incoming handshake/data packets demux to a route group by RouteDescriptor, and every candidate of one dial shares one descriptor, so two reciprocal handshakes cannot run concurrently on it. The race is thus two-phase: phase 1 reserves route IDs across all candidates CONCURRENTLY (where an unreachable intermediate fails id_reservation and loses cheaply -- the dominant live failure mode); phase 2 completes the handshake on reserved candidates fastest-reservation-first, first to complete wins. Because candidates are pre-reserved, moving to the next on a handshake failure costs no re-fetch and no re-reservation, unlike the old loop. Config: routing.parallel_route_setup (Config.ParallelRouteSetup, per-dial DialOptions.ParallelRouteSetup override). Default a small N (3); 1 restores the strictly-sequential behavior for a clean rollback. Concurrency is bounded (maxParallelRouteSetup) and by the number of candidates the finder returns. Loser penalty: a candidate that loses or fails setup has its intermediate hops marked suspect for a short TTL (suspectHopCache) so the NEXT dial front-loads known-good hops. Armed on EVERY failure path -- reservation failure, handshake timeout, AND ctx-deadline / setup-canceled -- which is the arming gap the companion suspect-hop work (#4063) alone did not cover live. Composition with #4063: the router holds a single `suspects` field consulted only in candidate ranking and armed only at the race's onLoser / DialRoutes failure paths; to reconcile, point those call sites at #4063's per-destination cache and drop this global one. Tests: winner selection, handshake-failure fall-through, all-fail, single- candidate sequential-equivalent, ctx-cancel, suspect cache TTL/arming, K resolution/clamping, and candidate ranking (suspect deprioritization, hard-exclude, disjoint preference). Race-detector clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (root-caused live)
Route-group setup walked candidate routes sequentially. In
DialRoutes, the policy picked ONE route,saveRouteGroupRulesset it up with one handshake + the #4057 retransmit overhandshakeAwaitTimeout(~10s); only on failure did a bounded retry loop query a fresh route and try the next candidate — one at a time. Each dead / non-forwarding candidate cost up to the full ~10s handshake await, so a run of bad candidates burned the whole ~90s dial ceiling →Route group setup canceled … context deadline exceededand the app wedged in "starting". Observed live: skysocks-client → Frankfurt atmin_hops>=2sitting in "starting" for minutes.Fix
DialRoutesnow sets up the top-K candidates concurrently; the first candidate to complete its reciprocal handshake wins and becomes the working base (the dial returns on it immediately — mux legs grow on top later and never unseat it). Losing / failed candidates are canceled + torn down. This collapses establishment from "up to N×handshakeAwaitTimeout" to ~one RTT and makes a dead-hop candidate lose the race instead of blocking — so the app converges to a working route (single multihop atmin_hops>=2, or direct atmin_hops==1) instead of wedging. min-hops-agnostic.Where the race goes
Top of the
DialRoutesretry loop, gated onK>1 && hookDone==nil && !forceLocal && !RouteSelectingHook. It fetches the top-K candidates in ONE route-finder query (fetchCandidateRoutes) and runsraceCandidateSetup. On total race failure it excludes the raced intermediates and falls through to the existing sequential path (with its local-calc fallback) in the same attempt. The old sequential path is fully preserved forK==1, cold-start (hookDone), local-route mode, and route-selecting policies. Post-setup wiring (mux/self-heal/hooks) was extracted intofinishDial, shared by both paths.Data-model constraint (why two phases)
Incoming handshake/data packets demux to a route group by RouteDescriptor, and every candidate of one dial shares one descriptor, so two reciprocal handshakes cannot run concurrently on it. The race is therefore two-phase:
id_reservationand loses cheaply (the dominant live failure mode);No leaked goroutines/route-IDs on cancel (child ctx + buffered result channel); winner's rules stay installed, losers' local rules removed (orphaned remote reservations expire via remote rule GC, same as failed candidates before).
Config knob
routing.parallel_route_setup→router.Config.ParallelRouteSetup, with a per-dialDialOptions.ParallelRouteSetupoverride. Default a small N (3);1restores strictly-sequential setup (clean rollback). Concurrency is bounded bymaxParallelRouteSetup(5) and by the number of candidates the finder returns.Loser penalty + composition with #4063
A candidate that loses or fails setup has its intermediate hops marked suspect for a short TTL (
suspectHopCache), so the NEXT dial front-loads known-good hops (soft deprioritize in candidate ranking, not a hard exclude). Armed on every failure path — reservation failure, handshake timeout, and ctx-deadline / setup-canceled — which is the arming gap #4063 alone did not fire on live.Composition with #4063 (
fix/router-suspect-hop-failover,pkg/router/suspect_hops.go, per-destination TTL suspect cache): the router holds a singlesuspectsfield, consulted only in candidate ranking and armed only at the race'sonLoser/DialRoutesfailure paths. To reconcile, point those call sites at #4063's per-destination cache (add the destination PK) and drop this global one — the two are structured to be swapped without touching the race logic.Tests
pkg/router/parallel_route_setup_test.go: winner selection (first handshake to complete wins), handshake-failure fall-through to the next reserved candidate, all-fail, single-candidate sequential-equivalent, ctx-cancel, suspect cache arm/TTL/count, K resolution + clamping, and candidate ranking (suspect deprioritization, hard-exclude, disjoint preference). Race-detector clean.Validation done
go build ./...(root + router + visorcore),go vet ./pkg/router/, targetedgo test ./pkg/router/ -race. Not merged — core routing; leaving for live validation.Suggested live validation (coordinator)
On the native visor: with
routing.parallel_route_setup: 3(default), start skysocks-client to a Frankfurt exit atmin_hops=2and measure re-establishment time after a route drop vs.parallel_route_setup: 1(sequential). Expect: sequential wedges/takes minutes on dead candidates; parallel reaches "running" in ~one RTT and never wedges in "starting". Also confirm amin_hops=1direct dial still behaves (single-candidate → sequential path, no race).