Skip to content

feat(chain): ordered exit preference with mid-session failover - #35

Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/multihop-preference
Open

feat(chain): ordered exit preference with mid-session failover#35
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/multihop-preference

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown

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:

  • SOCKS5 answered 0x05 to each request (src/proxy/socks5.js),
  • the HTTP proxy answered 502 (src/proxy/http.js),
  • trucvpn status still reported a healthy connection through the dead exit.

Only disconnect && connect by 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.js still 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.

preference:  mock-sg-1, vn, auto
   hop 1 ── mock-sg-1
   hop 2 ── vn exits, best first
   hop N ── direct-local  (no upstream — last resort only)

Acceptance criteria from the issue

Asked for Where
configure list trucvpn configure --prefer "sg,vn,auto", connect --prefer, exitPreference in config, prefer on POST /api/connect; trucvpn chain previews the resolution without connecting
failover test tests/failover-smoke.test.js — real sockets, two mock share nodes, first one killed mid-session; plus 8 unit failover cases in tests/chain.test.js
status shows active hop trucvpn status prints hop 3/6 mock-sg-1 [active] and every standby; active_hop + chain in status --json / GET /api/status

A 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.json quietly shortening the chain is the failure this feature exists to prevent.

direct means 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 if allowDirectFallback is 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:

після connect              hop=mock-vn-hcm  [vn-hcm:active  vn-hn:standby  sg-1:standby ...]
  request 1: OK body received

>>> VN share node killed (port 17890)

  request 2: OK body received
after VN died              hop=mock-sg-1    [vn-hcm:cooldown vn-hn:cooldown sg-1:active ...]

>>> VN back up, waiting out the 3s cooldown

  request 3: OK body received
after VN recovered         hop=mock-vn-hcm  [vn-hcm:active  vn-hn:degraded sg-1:standby ...]

session log:
   exit failover mock-vn-hcm -> mock-vn-hn (select)
   exit failover mock-vn-hn -> mock-sg-1 (select)
   exit failover mock-sg-1 -> mock-vn-hcm (select)

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 — optional dial(). Without one they behave exactly as before, so every existing caller (and tests/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 in status(). The direct tail is still described as <preferred>+direct-fallback with the same console note, so nothing that read those fields changes.
  • src/cli.jstrucvpn chain [--prefer LIST] [--json], --prefer on connect, --prefer / --failover-cooldown / --dial-timeout / --direct-fallback on configure, chain view in status.
  • src/dashboard.jsprefer on POST /api/connect, chain keys on /api/config. /api/status already returns active_hop and chain, so the native apps and extensions get the live hop without a new endpoint.
  • docs/multihop.md, README section.

Backwards compatible: --exit ID still wins (it just becomes the head of the chain instead of the only choice), --region still steers, and with every share node offline the session still degrades to direct with the same message.

Tests

$ node --test tests/*.test.js
# tests 37   # pass 37   # fail 0

23 new cases. The interesting ones:

  • traffic moves to the next hop when the active share node dies mid-session (real sockets, real SOCKS5 handshake);
  • a dead hop is not re-dialed while cooling down — the difference between a cooldown and a retry loop;
  • the session returns to the preferred hop once its cooldown expires;
  • backoff doubles per consecutive failure and stops at maxCooldownMs;
  • direct is only reached after every share exit has failed;
  • each hop is dialed at most once per request, so a fully dead chain fails fast instead of walking every hop twice;
  • an unset dialTimeoutMs must not read as 0Number(null) is 0, 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:

Mutation Result
proxies ignore dial() (old pinned behaviour) failover smoke test fails
failed hop never gets a cooldown 4 tests fail
both reverted 37/37 pass

Notes

  • Failover is per connection. Sockets already open through a hop that dies are not migrated — TCP cannot be moved without the peer noticing — they close and the next request lands on the new hop.
  • A share node that accepts TCP but never completes the handshake holds the chain for the dial timeout before the next hop is tried. That was already true of the single pinned exit; dialTimeoutMs now makes it tunable, and the default is unchanged.

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
@laurentketterle-hub

Copy link
Copy Markdown

QA Verification — TrucVPN#35

PR: feat(chain): ordered exit preference with mid-session failover
Author: @Vyacheslav-Tomashevskiy | SHA: fc6495c

CI: no checks

  • No checks

Tests

✅ no tests

Evidence

| Screenshots | ❌ |
| Video/GIF | ❌ |
| Logs | ✅ |

Verdict: ⚠️ EVIDENCE MISSING

Auto-verified via PR Verify Tool

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.

[100 MRG] Feature: multi-hop exit preference (share + fallback chain)

2 participants