mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
fix(mesh): skip peer-side recovery in openCrossNode on central instances (#1068)
The R1-A2 symmetric-callback work added a peer-side recovery branch to
MeshService.openCrossNode: when reverseDialer is null, kick
PeerToCentralMeshSessionDialer.ensureSession so the peer re-opens its
callback WS to central before the cross-node dispatch.
The guard `!this.reverseDialer` did not distinguish "I am a peer waiting
for central to dial in" from "I am central, and the reverseDialer concept
does not apply to me." On central, reverseDialer is always null (central
is the bridge initiator side and never has another node dial into it), so
central entered the recovery branch on every forward dispatch, found no
session (central has no mesh_centrals row), and destroyed the inbound
socket with route.resolve.fail forward-from-peer no_session.
Gate the branch on `MeshCentralRegistry.getActive() !== null` so it only
runs when this Sencho is acting as a proxy-mode peer that has actually
been bootstrapped. Central falls straight through to dialMeshTcpStream.
The peer cold-start scenario the comment originally described is still
covered: a proxy-mode peer with a cached mesh_centrals row but no live
WS to central will fire the recovery branch as before.
Adds two regression tests:
- central path (no mesh_centrals row, no reverseDialer) skips recovery
and calls dialMeshTcpStream
- proxy-peer path (mesh_centrals row + ensureSession returns null)
still emits route.resolve.fail forward-from-peer no_session
Existing tests in the "openCrossNode (BUG-4)" block already used a stub
reverseDialer to bypass the recovery branch, which is why the central
regression was not caught pre-release. The new tests deliberately leave
reverseDialer null to exercise the gating.
This commit is contained in:
@@ -920,6 +920,114 @@ describe('MeshService.openCrossNode (BUG-4)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService.openCrossNode peer-recovery gating', () => {
|
||||
// Regression cover for the v0.81.0 forward-direction break: on central
|
||||
// (no mesh_centrals row, no reverseDialer because central is the bridge
|
||||
// initiator side) the peer-recovery branch incorrectly fired, found no
|
||||
// PeerToCentralMeshSessionDialer session, and destroyed the inbound
|
||||
// socket with route.resolve.fail forward-from-peer no_session. The
|
||||
// intent of the branch was peer cold-start; the guard
|
||||
// `!this.reverseDialer` alone could not distinguish "I am central" from
|
||||
// "I am a peer waiting for central to dial in", because both states
|
||||
// present as reverseDialer===null.
|
||||
function makeFakeStream(streamId: number): MeshTcpStreamLike & EventEmitter {
|
||||
const ee = new EventEmitter() as MeshTcpStreamLike & EventEmitter & { destroyed: boolean };
|
||||
ee.destroyed = false;
|
||||
Object.defineProperty(ee, 'streamId', { value: streamId, writable: false });
|
||||
ee.write = vi.fn().mockReturnValue(true);
|
||||
ee.end = vi.fn();
|
||||
ee.destroy = vi.fn(() => { ee.destroyed = true; });
|
||||
return ee;
|
||||
}
|
||||
function makeFakeSocket(): { destroy: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn>; write: ReturnType<typeof vi.fn> } {
|
||||
return { destroy: vi.fn(), end: vi.fn(), on: vi.fn(), write: vi.fn() };
|
||||
}
|
||||
|
||||
// No stub reverseDialer here: the whole point is to exercise the
|
||||
// !reverseDialer code path. Reset the registry per test so the mesh_centrals
|
||||
// row state is deterministic.
|
||||
beforeEach(async () => {
|
||||
const { MeshCentralRegistry } = await import('../services/MeshCentralRegistry');
|
||||
MeshCentralRegistry.resetForTest();
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_centrals').run();
|
||||
MeshService.getInstance().setReverseDialer(null);
|
||||
});
|
||||
|
||||
it('central (no mesh_centrals row) skips peer-recovery and calls dialMeshTcpStream', async () => {
|
||||
const svc = MeshService.getInstance();
|
||||
const target: MeshTarget = {
|
||||
nodeId: 7, stack: 'audit-mesh-proxy', service: 'echo',
|
||||
port: 9002, alias: 'echo.audit-mesh-proxy.sencho-test-03.sencho',
|
||||
};
|
||||
const fakeStream = makeFakeStream(101);
|
||||
const dialSpy = vi.spyOn(
|
||||
svc as unknown as { dialMeshTcpStream: (t: MeshTarget) => MeshTcpStreamLike | null },
|
||||
'dialMeshTcpStream',
|
||||
).mockReturnValue(fakeStream);
|
||||
|
||||
const fakeSrc = makeFakeSocket();
|
||||
await (svc as unknown as { openCrossNode: (t: MeshTarget, s: unknown) => Promise<void> })
|
||||
.openCrossNode(target, fakeSrc);
|
||||
|
||||
const events = svc.getActivity({ limit: 50 });
|
||||
const dispatch = events.find((e) => e.type === 'route.dispatch');
|
||||
expect(dispatch).toBeDefined();
|
||||
|
||||
// The bug surface: a route.resolve.fail with direction=forward-from-peer
|
||||
// would mean central wrongly went through the peer-recovery branch.
|
||||
const wrongFail = events.find((e) =>
|
||||
e.type === 'route.resolve.fail'
|
||||
&& (e.details as { direction?: string } | undefined)?.direction === 'forward-from-peer',
|
||||
);
|
||||
expect(wrongFail).toBeUndefined();
|
||||
expect(dialSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fakeSrc.destroy).not.toHaveBeenCalled();
|
||||
fakeStream.emit('close');
|
||||
});
|
||||
|
||||
it('proxy-peer (mesh_centrals row + no session) still emits route.resolve.fail forward-from-peer no_session', async () => {
|
||||
const { MeshCentralRegistry } = await import('../services/MeshCentralRegistry');
|
||||
const { PeerToCentralMeshSessionDialer } = await import('../services/PeerToCentralMeshSessionDialer');
|
||||
MeshCentralRegistry.getInstance().upsert({
|
||||
centralInstanceId: 'central-uuid-test',
|
||||
centralApiUrl: 'https://central.example.com',
|
||||
callbackJwt: 'eyJhbGciOiJIUzI1NiJ9.fake.token',
|
||||
jwtIssuedAt: Math.floor(Date.now() / 1000),
|
||||
jwtExpiresAt: Math.floor(Date.now() / 1000) + 90 * 24 * 3600,
|
||||
});
|
||||
const ensureSpy = vi.spyOn(
|
||||
PeerToCentralMeshSessionDialer.getInstance(),
|
||||
'ensureSession',
|
||||
).mockResolvedValue(null);
|
||||
|
||||
const svc = MeshService.getInstance();
|
||||
const target: MeshTarget = {
|
||||
nodeId: 1, stack: 'audit-mesh-central', service: 'echo',
|
||||
port: 9000, alias: 'echo.audit-mesh-central.Local.sencho',
|
||||
};
|
||||
const dialSpy = vi.spyOn(
|
||||
svc as unknown as { dialMeshTcpStream: (t: MeshTarget) => MeshTcpStreamLike | null },
|
||||
'dialMeshTcpStream',
|
||||
);
|
||||
|
||||
const fakeSrc = makeFakeSocket();
|
||||
await (svc as unknown as { openCrossNode: (t: MeshTarget, s: unknown) => Promise<void> })
|
||||
.openCrossNode(target, fakeSrc);
|
||||
|
||||
const events = svc.getActivity({ limit: 50 });
|
||||
const fail = events.find((e) =>
|
||||
e.type === 'route.resolve.fail'
|
||||
&& (e.details as { direction?: string; reason?: string } | undefined)?.direction === 'forward-from-peer'
|
||||
&& (e.details as { reason?: string } | undefined)?.reason === 'no_session',
|
||||
);
|
||||
expect(fail).toBeDefined();
|
||||
expect(ensureSpy).toHaveBeenCalled();
|
||||
// Peer-recovery aborted dispatch before reaching dialMeshTcpStream.
|
||||
expect(dialSpy).not.toHaveBeenCalled();
|
||||
expect(fakeSrc.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService pilot handleAccept dispatch', () => {
|
||||
function makeEnrollToken(nodeId: number): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
|
||||
@@ -1589,36 +1589,48 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
message: `cross-node dispatch to ${target.alias} on node ${target.nodeId}`,
|
||||
});
|
||||
|
||||
// Peer-side recovery: when no reverseDialer is installed, kick the
|
||||
// symmetric-dial path so central learns about this peer. The
|
||||
// proactive back-dial from central (registered as a side effect via
|
||||
// the proxy-tunnel WS upgrade) is what actually installs the
|
||||
// reverseDialer on this peer. If the session cannot be established
|
||||
// (cache miss, central down, auth rejected), log route.resolve.fail
|
||||
// and drop the inbound socket.
|
||||
// Peer-side recovery: when this Sencho is acting as a proxy-mode peer
|
||||
// (mesh_centrals has a row from a prior bootstrap) and the
|
||||
// reverseDialer is not currently installed, the inbound bridge from
|
||||
// central is either cold-start (peer just rebooted) or has been torn
|
||||
// down (idle close, central restart). Kick the symmetric-dial path
|
||||
// so the peer re-opens its callback WS to central before attempting
|
||||
// the cross-node dispatch.
|
||||
//
|
||||
// Central instances never have a mesh_centrals row (central is not a
|
||||
// peer of itself), so this branch is correctly skipped on central.
|
||||
// Central falls straight through to dialMeshTcpStream which uses its
|
||||
// own PilotTunnelManager + MeshProxyTunnelDialer to reach the target
|
||||
// peer. Without this gate, central enters the branch on every
|
||||
// forward dispatch, finds no session, and destroys the inbound
|
||||
// socket with route.resolve.fail forward-from-peer no_session.
|
||||
if (!this.reverseDialer) {
|
||||
try {
|
||||
const { PeerToCentralMeshSessionDialer } = await import('./PeerToCentralMeshSessionDialer');
|
||||
const session = await PeerToCentralMeshSessionDialer.getInstance().ensureSession();
|
||||
if (!session) {
|
||||
const { MeshCentralRegistry } = await import('./MeshCentralRegistry');
|
||||
const isProxyPeer = MeshCentralRegistry.getInstance().getActive() !== null;
|
||||
if (isProxyPeer) {
|
||||
try {
|
||||
const { PeerToCentralMeshSessionDialer } = await import('./PeerToCentralMeshSessionDialer');
|
||||
const session = await PeerToCentralMeshSessionDialer.getInstance().ensureSession();
|
||||
if (!session) {
|
||||
this.logActivity({
|
||||
source: 'mesh', level: 'warn', type: 'route.resolve.fail',
|
||||
nodeId: target.nodeId, alias: target.alias,
|
||||
message: `peer cross-node dispatch failed: no central callback session available`,
|
||||
details: { direction: 'forward-from-peer', reason: 'no_session' },
|
||||
});
|
||||
try { src.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logActivity({
|
||||
source: 'mesh', level: 'warn', type: 'route.resolve.fail',
|
||||
nodeId: target.nodeId, alias: target.alias,
|
||||
message: `peer cross-node dispatch failed: no central callback session available`,
|
||||
details: { direction: 'forward-from-peer', reason: 'no_session' },
|
||||
message: `peer cross-node dispatch failed: bootstrap threw`,
|
||||
details: { direction: 'forward-from-peer', reason: 'bootstrap_threw' },
|
||||
});
|
||||
try { src.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logActivity({
|
||||
source: 'mesh', level: 'warn', type: 'route.resolve.fail',
|
||||
nodeId: target.nodeId, alias: target.alias,
|
||||
message: `peer cross-node dispatch failed: bootstrap threw`,
|
||||
details: { direction: 'forward-from-peer', reason: 'bootstrap_threw' },
|
||||
});
|
||||
try { src.destroy(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user