Files
sencho/backend
Anso 0b4cf90bf1 fix(mesh): peer-initiated callback bridge installs reverse dialer (#1071)
* fix(mesh): peer-initiated callback bridge installs reverse dialer

The R1-A2 symmetric-dial work added a peer-side dialer that opens a
callback WS to central at `/api/mesh/proxy-tunnel-from-peer`. The WS
upgrade, JWT validation, and `mesh_centrals` persistence all worked,
but the peer-side handler put the wrong object on its end of the pipe:
a `PilotTunnelBridge` (the central-role object) rather than a
`TcpStreamSwitchboard` with a registered `reverseDialer`.

The design doc states: "Central retains PilotTunnelBridge ownership;
peer retains TcpStreamSwitchboard + reverseDialer ownership." The
central-initiated handler at `meshProxyTunnel.ts:115-163` honors this.
The peer-initiated handler at `PeerToCentralMeshSessionDialer.attachBridge`
did not. It created a `PilotTunnelBridge(0, ws)` and stored it in a
private `currentSession` field. Result: `MeshService.reverseDialer`
stayed null, `dialMeshTcpStream` fell through to
`PilotTunnelManager.ensureBridge(target.nodeId)`, and `NodeRegistry.
getProxyTarget` on the peer returned null because proxy-mode peers
do not enroll their central. Cross-fleet dispatch from a peer
container failed with `proxy-tunnel.open.fail nodeId=<central>
reason=no_target` even though the callback bridge was up and healthy
(`bridgeOpen: true`, `last_used_at` populated).

Replace the peer-side `PilotTunnelBridge` with the same wiring the
central-initiated handler uses:

  - `attachTcpStreamSwitchboard` with `resolveByComposeLabels` (allows
    inbound `tcp_open` from central if it ever uses the callback
    bridge for a reverse-direction dispatch; matches the central-
    initiated pipe's resolver).
  - A `SwitchboardReverseDialer` that delegates `openMeshTcpStream`
    to `switchboard.openReverseStream`.
  - `MeshService.setReverseDialer(localDialer, null)` with the same
    CAS guard the central-initiated handler uses, so a concurrent
    central-initiated tunnel does not get silently overwritten.
  - `ws.on('message', onMessage)` dispatches JSON / binary frames to
    the switchboard.
  - `ws.once('close'/'error', teardown)` clears the switchboard, the
    reverseDialer, and the `currentSession` reference idempotently.

The `currentSession` field type changes from `PilotTunnelBridge | null`
to `TcpStreamSwitchboard | null`. Callers in `MeshService.openCrossNode`
only use the return value for truthiness; the cast is harmless. The
public `hasSession()` signature is unchanged.

A new `currentWs` field holds the WS reference so `resetForTest` can
close the connection on teardown (the switchboard's own `cleanup`
does not close its WS).

Test update mirrors the production wiring: the existing
"marks the row used on successful WS open" case now asserts the
return value is a `TcpStreamSwitchboard`, that `MeshService.reverseDialer`
is non-null after `ensureSession`, and explicitly closes the WS in
the test's finally so `wss.close` can resolve.

Pre-existing test logic that asserted `bridge instanceof PilotTunnelBridge`
on the same path is removed in favor of the new switchboard assertion.

Discovered during the v0.81.1 live verification: the peer-side
`route.dispatch cross-node` event always paired with
`proxy-tunnel.open.fail no_target` and `tunnel.fail`, even though
`centralCallback.bridgeOpen: true` and `mesh_centrals.last_used_at`
were populated. The callback path opened cleanly in isolation but
the dispatch path never consumed it. This fix makes the dispatch
path consume it.

Tests:
  - cd backend && npx tsc --noEmit          clean
  - cd backend && npx vitest run mesh        170/170 green
  - cd backend && npx vitest run             2259/2268 green; only
    failures are the pre-existing Windows EBUSY flake in
    filesystem-backup.test.ts (documented in prior handoff)

* fix(mesh): drop unused imports and align reverse-dialer interface name

CI lint failures on the previous push were two @typescript-eslint/no-unused-vars
errors in the test file:

  24:76  'vi' is defined but never used
  31:5   'TcpStreamSwitchboardCtor' is assigned a value but only used as a type

Both were collateral from a local-debugging simplification: vi.waitFor was
swapped for a synchronous assertion when the test's wss.close hang was traced
to an unclosed client WS; the toBeInstanceOf assertion that used the runtime
binding was removed at the same time. Restore the instanceof assertion next
to the existing not.toBeNull check so the runtime binding is in use and the
test guards against future regressions where the wrong end-of-pipe object
type is returned on the callback bridge (the bug this PR fixes). Drop the
now-unused `vi` symbol from the vitest import.

Also fold in a /simplify convergent finding: rename the local
CallbackReverseDialer interface to SwitchboardReverseDialer to match the
sibling declaration in meshProxyTunnel.ts:72. Same shape, same name; readers
of either handler now see the same mental model. Cross-file extraction of
the interface to tcpStreamSwitchboard.ts is the cleaner long-term shape but
touches a file outside this PR's scope; filing as a follow-up.

Considered-and-deferred findings from the /simplify pass (file as separate
PRs to keep one-branch-one-concern):
  - extract shared attachSwitchboard helper (~40 duplicated lines between
    meshProxyTunnel.ts and PeerToCentralMeshSessionDialer.ts)
  - public TcpStreamSwitchboard.getWs accessor + drop currentWs field
  - public MeshService.hasReverseDialer for the test bracket-cast
  - shared close-reason constants (drift risk across the two handlers)

Tests:
  - cd backend && npx tsc --noEmit          clean
  - cd backend && npx eslint <changed>      clean (lint_exit=0)
  - cd backend && npx vitest run            mesh 170/170, dialer 8/8 green
2026-05-16 20:11:31 -04:00
..