mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 20:59:09 +00:00
fix(mesh): persist proxy-tunnel self-id across bridge teardown (#1060)
The proxy-mode WS handler called setProxyTunnelSelfCentralNodeId(null) on tunnel close, dropping the central-namespace nodeId install. After an idle close, the next inbound forwarder traffic on the peer fell back to getDefaultNodeId() (=1 on proxy peers without SENCHO_ENROLL_TOKEN), collided with the alias overlay's nodeId for central's own Local, and dispatched cross-fleet aliases to the same-node path. The signature was TCP open + no banner on the prober, with route.resolve.denied on the peer activity log even though R1's identify install fired correctly when the bridge was warm. The peer's identity in central's namespace is enrollment-stable, not per-WS. Stop null-clearing on teardown. The setter already emits a console.warn when the install is overwritten with a different non-null value, which is the right signal for an actual re-enrollment. Tests: existing assertion that the install becomes null on close is flipped to assert persistence, plus two new tests for the close-reopen lifecycle: same nodeId is idempotent (no second identify activity event), different nodeId emits the overwrite warn. Idempotency test polls deterministically for the first identify event before snapshotting the activity-count baseline so the assertion direction can't invert on a slow CI runner.
This commit is contained in:
@@ -157,16 +157,95 @@ describe('handleMeshProxyTunnel', () => {
|
||||
const ws = await dialTunnel(srv.port, '?nodeId=14');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect((MeshService.getInstance() as unknown as { proxyTunnelSelfCentralNodeId: number | null }).proxyTunnelSelfCentralNodeId).toBe(14);
|
||||
|
||||
ws.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Tunnel close clears the value.
|
||||
expect((MeshService.getInstance() as unknown as { proxyTunnelSelfCentralNodeId: number | null }).proxyTunnelSelfCentralNodeId).toBeNull();
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the install persists across WS close (enrollment-stable, not per-bridge)', async () => {
|
||||
// Null-clearing on teardown previously caused handleAccept to fall
|
||||
// back to getDefaultNodeId() after idle close, misdispatching
|
||||
// cross-fleet aliases to the same-node path.
|
||||
const srv = await startServer();
|
||||
try {
|
||||
const ws = await dialTunnel(srv.port, '?nodeId=14');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
ws.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect((MeshService.getInstance() as unknown as { proxyTunnelSelfCentralNodeId: number | null }).proxyTunnelSelfCentralNodeId).toBe(14);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-opening the tunnel with the same nodeId is idempotent (no second identify event)', async () => {
|
||||
const srv = await startServer();
|
||||
const svc = MeshService.getInstance();
|
||||
const identifyCount = () => svc.getActivity({ limit: 200 })
|
||||
.filter((e) => e.type === 'mesh.proxy_tunnel.identify').length;
|
||||
const waitForCount = async (atLeast: number, timeoutMs = 500): Promise<void> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs && identifyCount() < atLeast) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
try {
|
||||
const wsA = await dialTunnel(srv.port, '?nodeId=14');
|
||||
// Snapshot after the first identify event has deterministically
|
||||
// landed, so the race window between WS-open and async install
|
||||
// can't make beforeCount=0 on a slow CI.
|
||||
await waitForCount(1);
|
||||
const beforeCount = identifyCount();
|
||||
expect(beforeCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
wsA.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const wsB = await dialTunnel(srv.port, '?nodeId=14');
|
||||
// Settle window for any (incorrect) second identify; small but
|
||||
// deterministic because the assertion below is "no new event"
|
||||
// and a longer wait wouldn't change the answer.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect((svc as unknown as { proxyTunnelSelfCentralNodeId: number | null }).proxyTunnelSelfCentralNodeId).toBe(14);
|
||||
expect(identifyCount()).toBe(beforeCount);
|
||||
|
||||
wsB.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-opening the tunnel with a different nodeId emits the overwrite warn (re-enrollment signal)', async () => {
|
||||
const srv = await startServer();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => { warns.push(args.map(String).join(' ')); };
|
||||
try {
|
||||
const wsA = await dialTunnel(srv.port, '?nodeId=14');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
wsA.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const wsB = await dialTunnel(srv.port, '?nodeId=15');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const svc = MeshService.getInstance();
|
||||
expect((svc as unknown as { proxyTunnelSelfCentralNodeId: number | null }).proxyTunnelSelfCentralNodeId).toBe(15);
|
||||
|
||||
const overwriteWarns = warns.filter((w) => w.includes('proxyTunnelSelfCentralNodeId overwritten'));
|
||||
expect(overwriteWarns.length).toBeGreaterThanOrEqual(1);
|
||||
expect(overwriteWarns[overwriteWarns.length - 1]).toContain('14 -> 15');
|
||||
|
||||
wsB.close(1000, 'test cleanup');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not install or reject when the nodeId query param is missing (warns and proceeds)', async () => {
|
||||
const srv = await startServer();
|
||||
try {
|
||||
|
||||
@@ -114,14 +114,19 @@ async function attachSwitchboard(ws: WebSocket, peerNodeId: number | null): Prom
|
||||
// Install central's view of this peer's nodeId so handleAccept
|
||||
// dispatches cross-node aliases correctly. Done after setReverseDialer
|
||||
// succeeds so a CAS-rejected tunnel does not leak nodeId state.
|
||||
//
|
||||
// The install persists across bridge lifecycles: the peer's identity
|
||||
// in central's namespace is stable for the enrollment, not per-WS.
|
||||
// Null-clearing on teardown caused dispatch to fall back to
|
||||
// getDefaultNodeId() after idle close, which collides with central's
|
||||
// own nodeId for Local and misdispatches cross-fleet traffic to the
|
||||
// same-node path. A subsequent install with a different nodeId
|
||||
// (e.g. re-enrollment) is detected by the setter's overwrite-warn.
|
||||
if (peerNodeId !== null) {
|
||||
meshService.setProxyTunnelSelfCentralNodeId(peerNodeId);
|
||||
}
|
||||
meshServiceCleanup = () => {
|
||||
meshService.setReverseDialer(null, localDialer);
|
||||
if (peerNodeId !== null) {
|
||||
meshService.setProxyTunnelSelfCentralNodeId(null);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (isDebugEnabled()) {
|
||||
|
||||
Reference in New Issue
Block a user