Skip to content

feat(router): parallel candidate route-group setup (steady-connection fix) - #4078

Merged
0pcom merged 1 commit into
skycoin:developfrom
0pcom:feat/parallel-candidate-route-setup
Aug 21, 2026
Merged

0pcom merged 1 commit into
skycoin:developfrom
0pcom:feat/parallel-candidate-route-setup

Conversation

@0pcom

@0pcom 0pcom commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Problem (root-caused live)

Route-group setup walked candidate routes sequentially. In DialRoutes, the policy picked ONE route, saveRouteGroupRules set it up with one handshake + the #4057 retransmit over handshakeAwaitTimeout (~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 exceeded and the app wedged in "starting". Observed live: skysocks-client → Frankfurt at min_hops>=2 sitting in "starting" for minutes.

Fix

DialRoutes now 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 at min_hops>=2, or direct at min_hops==1) instead of wedging. min-hops-agnostic.

Where the race goes

Top of the DialRoutes retry loop, gated on K>1 && hookDone==nil && !forceLocal && !RouteSelectingHook. It fetches the top-K candidates in ONE route-finder query (fetchCandidateRoutes) and runs raceCandidateSetup. 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 for K==1, cold-start (hookDone), local-route mode, and route-selecting policies. Post-setup wiring (mux/self-heal/hooks) was extracted into finishDial, 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:

  • phase 1 (concurrent): reserve route IDs across every candidate's hops — where an unreachable intermediate fails id_reservation and loses cheaply (the dominant live failure mode);
  • phase 2 (serial, fastest-reservation-first): complete the handshake on reserved candidates; 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.

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_setuprouter.Config.ParallelRouteSetup, with a per-dial DialOptions.ParallelRouteSetup override. Default a small N (3); 1 restores strictly-sequential setup (clean rollback). Concurrency is bounded by maxParallelRouteSetup (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 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 (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/, targeted go 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 at min_hops=2 and 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 a min_hops=1 direct dial still behaves (single-candidate → sequential path, no race).

… 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.
@0pcom
0pcom merged commit 22eb895 into skycoin:develop Aug 21, 2026
12 of 16 checks passed
@0pcom
0pcom deleted the feat/parallel-candidate-route-setup branch August 24, 2026 23:45
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.

1 participant