feat(chain): ordered exit preference with mid-session failover - #35
Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Open
Conversation
A session resolved one exit at connect time and pinned it: getExit() handed the proxies the same object for the whole session. When the share node behind it went away - a sharer closing their laptop is the normal case - every later dial failed (SOCKS 0x05, HTTP 502) while status still reported a healthy connection, and only a manual reconnect fixed it. An exit chain is the ordered list of exits a session may use. Traffic rides the first hop that answers; a hop that cannot take a connection goes on a cooldown and the next takes over per connection, mid-session. When a more preferred hop's cooldown expires it is tried first again, so the session drifts back up the list without a background health loop. - src/chain.js: preference parsing (exit id / region / name fragment / auto / direct), chain resolution with auto-filled tail, per-hop health, exponential cooldown, dial-through-chain, status snapshot. - proxies take an optional dial(); without one they behave exactly as before. - session builds the chain, probes hops in order at connect, reports the live active hop in status; the direct tail is still described as a fallback. - CLI: trucvpn chain, --prefer, --failover-cooldown, --dial-timeout, --direct-fallback; status prints the active hop and every standby. - daemon: prefer on POST /api/connect, chain keys on /api/config; /api/status already carries active_hop and chain. - docs/multihop.md, README. Tests: 23 new (chain unit + real-socket failover smoke with a control case reproducing the old pinned behaviour). Full suite 37 passing. Fixes mergeos-bounties#7
QA Verification — TrucVPN#35PR: feat(chain): ordered exit preference with mid-session failover CI: no checks
Tests✅ no tests Evidence| Screenshots | ❌ | Verdict:
|
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.
Fixes #7
What was wrong
A session resolved one exit at connect time and pinned it for its whole life —
getExit()handed the local SOCKS5/HTTP proxies the same object on every request (src/session.js,const getExit = () => effectiveExit).So when the share node behind it went away — a sharer closing their laptop is the normal case, not the exceptional one — every later dial through it failed:
0x05to each request (src/proxy/socks5.js),502(src/proxy/http.js),trucvpn statusstill reported a healthy connection through the dead exit.Only
disconnect && connectby hand recovered it, and there was no way to say "Singapore, but Vietnam if Singapore is down".The control test at the bottom of
tests/failover-smoke.test.jsstill reproduces exactly that: kill the share node, and the pinned-exit path refuses every request.What this adds
An exit chain: the ordered list of exits a session may use, most preferred first. Traffic rides the first hop that answers; a hop that cannot take a connection is put on a cooldown and the next takes over per connection, mid-session. When a more preferred hop's cooldown expires it is tried first again, so the session drifts back up the list on its own — no background health loop, no timer to leak.
Acceptance criteria from the issue
trucvpn configure --prefer "sg,vn,auto",connect --prefer,exitPreferencein config,preferonPOST /api/connect;trucvpn chainpreviews the resolution without connectingtests/failover-smoke.test.js— real sockets, two mock share nodes, first one killed mid-session; plus 8 unit failover cases intests/chain.test.jstrucvpn statusprintshop 3/6 mock-sg-1 [active]and every standby;active_hop+chaininstatus --json/GET /api/statusA preference entry is an exit id, a region code, a name fragment, or the wildcards
auto/direct. Entries that match nothing are reported, not silently dropped — a typo in~/.trucvpn/config.jsonquietly shortening the chain is the failure this feature exists to prevent.directmeans no residential exit at all, so auto-fill never adds it on its own (it reports 1 ms and would win the chain outright) — it is appended as the tail, and only kept there ifallowDirectFallbackis on. Naming it explicitly keeps it wherever you put it.Verified live, not just in unit tests
Two mock share nodes on the ports the sample catalog points at, one real session, the VN node killed and then brought back:
The session never dropped, and nothing was reconnected by hand.
Changes
src/chain.js(new) — preference parsing, chain resolution with auto-filled tail, per-hop health, exponential cooldown capped at 5 min, dial-through-chain, status snapshot.src/proxy/socks5.js,src/proxy/http.js— optionaldial(). Without one they behave exactly as before, so every existing caller (andtests/connect-smoke.test.js) is untouched. The log line now reads the exit after the dial, since the hop that served the connection may not be the one it started on.src/session.js— builds the chain, probes hops in order at connect (unreachable ones go straight onto a cooldown instead of being rediscovered by the first request), reports the live active hop instatus(). The direct tail is still described as<preferred>+direct-fallbackwith the same console note, so nothing that read those fields changes.src/cli.js—trucvpn chain [--prefer LIST] [--json],--preferonconnect,--prefer/--failover-cooldown/--dial-timeout/--direct-fallbackonconfigure, chain view instatus.src/dashboard.js—preferonPOST /api/connect, chain keys on/api/config./api/statusalready returnsactive_hopandchain, so the native apps and extensions get the live hop without a new endpoint.docs/multihop.md, README section.Backwards compatible:
--exit IDstill wins (it just becomes the head of the chain instead of the only choice),--regionstill steers, and with every share node offline the session still degrades to direct with the same message.Tests
23 new cases. The interesting ones:
maxCooldownMs;dialTimeoutMsmust not read as0—Number(null)is0, and a 0 ms timeout arms a timer that fires on the next tick and rejects every dial, healthy hop or not. I hit this while writing the feature; the regression test pins it.Mutation-checked, both directions:
dial()(old pinned behaviour)Notes
dialTimeoutMsnow makes it tunable, and the default is unchanged.