Skip to content

Commit 8784b89

Browse files
committed
Merge pull request 'fix(remote): scope connection teardown by connection, not device' (#190) from fix/remote-connection-lifecycle into main
Reviewed-on: https://git.i.wylde.net/markwylde/terminay/pulls/190
2 parents ea8112a + a8a9f48 commit 8784b89

102 files changed

Lines changed: 2534 additions & 4546 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/trigger-release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,16 @@ jobs:
477477
VERSION="${TAG#v}"
478478
node scripts/sync-package-version.mjs "$VERSION"
479479
480+
- name: Prepare integrity-pinned Secure Werift source mirror
481+
run: |
482+
node scripts/prove-secure-werift-offline-rebuild.mjs --prepare-mirror "$RUNNER_TEMP/secure-werift-source-mirror"
483+
484+
- name: Prove clean network-independent Secure Werift rebuild
485+
env:
486+
npm_config_offline: "true"
487+
run: |
488+
node scripts/prove-secure-werift-offline-rebuild.mjs --prove-mirror "$RUNNER_TEMP/secure-werift-source-mirror"
489+
480490
- name: Build and pack exact standalone server artifact
481491
env:
482492
TAG: ${{ needs.release.outputs.tag }}

apps/terminay-desktop/test/embedded-runtime-artifact.test.mjs

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,6 @@ function run(command, args, options = {}) {
4646
});
4747
}
4848

49-
function runFailure(command, args, options = {}) {
50-
return new Promise((resolve, reject) => {
51-
const child = spawn(command, args, {
52-
cwd: options.cwd,
53-
env: options.env,
54-
stdio: ["ignore", "pipe", "pipe"],
55-
});
56-
let stdout = "";
57-
let stderr = "";
58-
child.stdout.setEncoding("utf8");
59-
child.stderr.setEncoding("utf8");
60-
child.stdout.on("data", (chunk) => { stdout += chunk; });
61-
child.stderr.on("data", (chunk) => { stderr += chunk; });
62-
child.once("error", reject);
63-
child.once("close", (code) => {
64-
if (code !== 0) resolve({ code, stdout, stderr });
65-
else reject(new Error(`${command} ${args.join(" ")} unexpectedly succeeded`));
66-
});
67-
});
68-
}
6949

7050
function startForeground(command, args, options = {}) {
7151
const child = spawn(command, args, {

apps/terminay-desktop/test/server-bundle-host.test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ function tar(entries) {
121121
header.write('0000000\0', 108, 'ascii');
122122
header.write('0000000\0', 116, 'ascii');
123123
header.write(
124-
body.length.toString(8).padStart(11, '0') + '\0',
124+
`${body.length.toString(8).padStart(11, '0')}\0`,
125125
124,
126126
'ascii',
127127
);
@@ -132,7 +132,7 @@ function tar(entries) {
132132
header.write('00', 263, 'ascii');
133133
let sum = 0;
134134
for (const byte of header) sum += byte;
135-
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
135+
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
136136
records.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512));
137137
}
138138
return Buffer.concat([...records, Buffer.alloc(1024)]);

apps/terminay-server/src/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ export * from "./localUiServer.js";
1414
export * from "./healthServer.js";
1515
export * from "./remote/nodeDataChannelRuntime.js";
1616
export * from "./remote/nodeDataChannelPeer.js";
17-
export * from "./remote/nodeDataChannelHost.js";
1817
export * from "./remote/secureWeriftRuntime.js";
1918
export * from "./remote/secureWeriftPeer.js";
20-
export * from "./remote/secureWeriftHost.js";
2119
export * from "./remote/serverExposure.js";
2220
export * from "./remote/signalingHostBoundary.js";
2321
export * from "./remote/hostedSignalingRegistrar.js";

apps/terminay-server/src/remote/hostedPairingHost.ts

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,33 +30,33 @@ import {
3030
import type { ServerPairingHandoff, ServerRemoteExposure } from './serverExposure.js';
3131
import { bindUiArchiveChannels, safeChannelSend } from './uiArchiveTransfer.js';
3232
import {
33-
applyHostedLaneDiagnostic,
3433
collectHostIceAddresses,
3534
createHandshakeJoinQueue,
3635
DEVICE_HOST_AVAILABILITY_MS,
3736
deviceHostRefreshDelayMs,
3837
type HostedIceServer,
39-
HostedGenerationSet,
38+
HostedLivePeerRegistry,
4039
HostedPeerLifecycle,
4140
hostedPeerConfiguration,
41+
requiredLaneClosed,
4242
resolveIceRecoveryGraceMs,
43-
shouldFailHostedStall,
4443
} from './hostedPeerLifecycle.js';
4544
import { createHostedStreamDiagnostics, frameByteLength } from './hostedStreamDiagnostics.js';
4645

4746
export {
48-
applyHostedLaneDiagnostic,
4947
collectHostIceAddresses,
5048
createHandshakeJoinQueue,
5149
DEFAULT_HOSTED_ICE_SERVERS,
5250
DEFAULT_ICE_RECOVERY_GRACE_MS,
5351
DEVICE_HOST_AVAILABILITY_MS,
5452
DEVICE_REFRESH_LEAD_MS,
5553
deviceHostRefreshDelayMs,
56-
HostedGenerationSet,
54+
HostedLivePeerRegistry,
5755
HostedPeerLifecycle,
5856
hostedPeerConfiguration,
5957
parseHostedIceServers,
58+
REQUIRED_LANES,
59+
requiredLaneClosed,
6060
resolveHostedIceServers,
6161
resolveIceRecoveryGraceMs,
6262
} from './hostedPeerLifecycle.js';
@@ -166,14 +166,12 @@ export type HostedPairingDiagnostic = Readonly<{
166166
readonly firstInboundAgeMs?: number | null;
167167
readonly firstOutboundAgeMs?: number | null;
168168
readonly liveGenerationCount?: number;
169-
readonly stallIgnored?: boolean;
170169
readonly hangup?: boolean;
171170
readonly inboundKind?: 'bytes' | 'blob' | 'string' | 'empty' | 'other' | undefined;
172171
readonly droppedFrames?: number;
173172
readonly droppedClass?: 'bytes' | 'blob' | 'string' | 'empty' | 'other';
174173
readonly sendFailures?: number;
175174
readonly sendFailure?: boolean;
176-
readonly stallClass?: 'no-outbound' | 'outbound-stalled';
177175
readonly first?: 'inbound' | 'outbound';
178176
readonly summary?: boolean;
179177
readonly reasonClass?: string;
@@ -204,8 +202,8 @@ export async function startHostedPairingHost(
204202
const archive = options.getUiArchive
205203
? await options.getUiArchive()
206204
: createMinimalUiArchive();
207-
const generations = new HostedGenerationSet();
208-
const context = { archive, options, generations };
205+
const livePeers = new HostedLivePeerRegistry();
206+
const context = { archive, options, livePeers };
209207
const joinQueue = createHandshakeJoinQueue();
210208
let handshakeGeneration = 0;
211209
let handshake:
@@ -234,14 +232,30 @@ export async function startHostedPairingHost(
234232
clearTimeout(pairingRefreshTimer);
235233
clearTimeout(deviceRefreshTimer);
236234
handshake?.peer.close();
237-
generations.closeAll();
235+
await livePeers.closeAll();
238236
pairingGeneration += 1;
239237
deviceGeneration += 1;
240238
closeSocket(pairingSocket);
241239
closeSocket(deviceSocket);
242240
};
243241

244242
async function addHandshakePeer(socket: WebSocket, scope: SignalScope): Promise<void> {
243+
// A device gets one live peer. Retire the connection this join replaces
244+
// before its replacement exists, so the two never overlap and a late
245+
// teardown cannot reach the new session's terminals.
246+
if (scope.kind === 'device') {
247+
const replaced = await livePeers.close(scope.deviceId);
248+
if (replaced !== undefined) {
249+
diagnose({ type: 'peer-closed', reasonClass: 'replaced-by-rejoin' });
250+
// Report the retirement here rather than relying on the native
251+
// datachannel emitting `close` before its peer is torn down. That
252+
// event is not guaranteed to arrive, and a missed one would leave a
253+
// replaced connection listed as live for the rest of the session.
254+
if (replaced.connectionId !== undefined) {
255+
options.onPeerDisconnected?.(replaced.connectionId);
256+
}
257+
}
258+
}
245259
const generation = ++handshakeGeneration;
246260
if (handshake) {
247261
handshake.peer.close();
@@ -253,10 +267,18 @@ export async function startHostedPairingHost(
253267
scope,
254268
context,
255269
(connection, peer) => {
256-
if (handshake?.peer === next) {
257-
generations.add({ peer: next, connection });
258-
handshake = undefined;
270+
// A newer join replaced this handshake, or the host stopped, while
271+
// this one was authenticating. Retire it rather than leaving an
272+
// untracked live connection for the device: nothing would close it,
273+
// and it is exactly the superseded generation this design removes.
274+
if (handshake?.peer !== next || closed) {
275+
void connection.close();
276+
next.close();
277+
diagnose({ type: 'peer-closed', reasonClass: 'replaced-by-rejoin' });
278+
return;
259279
}
280+
livePeers.set(peer.deviceId, { peer: next, connection, connectionId: peer.connectionId });
281+
handshake = undefined;
260282
options.onPeerConnected?.(peer);
261283
if (scope.kind === 'pairing') {
262284
clearTimeout(pairingRefreshTimer);
@@ -266,8 +288,8 @@ export async function startHostedPairingHost(
266288
pairingRefreshTimer.unref?.();
267289
}
268290
},
269-
(retired) => {
270-
generations.drop(retired);
291+
(deviceId, retired) => {
292+
if (deviceId !== undefined) livePeers.drop(deviceId, retired);
271293
},
272294
);
273295
if (closed || generation !== handshakeGeneration) {
@@ -742,11 +764,11 @@ async function startPeer(
742764
scope: SignalScope,
743765
context: Readonly<{
744766
archive: MinimalArchive;
745-
generations: HostedGenerationSet;
767+
livePeers: HostedLivePeerRegistry;
746768
options: HostedPairingHostOptions;
747769
}>,
748770
onApplication: (connection: ServerConnectionLike, peer: HostedConnectedPeer) => void,
749-
onRetire: (peer: WeriftPeer) => void,
771+
onRetire: (deviceId: string | undefined, peer: WeriftPeer) => void,
750772
): Promise<WeriftPeer> {
751773
const native = new Peer(
752774
hostedPeerConfiguration(
@@ -760,15 +782,10 @@ async function startPeer(
760782
let wrapped: WeriftPeer;
761783
const stream = createHostedStreamDiagnostics({
762784
emit: (event) => {
763-
const enriched = {
785+
context.options.onDiagnostic?.({
764786
...event,
765-
liveGenerationCount: context.generations.size,
766-
...(event.stallClass === undefined
767-
? {}
768-
: { stallIgnored: !shouldFailHostedStall(event) }),
769-
};
770-
context.options.onDiagnostic?.(enriched);
771-
applyHostedLaneDiagnostic(lifecycle, enriched);
787+
liveGenerationCount: context.livePeers.size,
788+
});
772789
},
773790
});
774791
lifecycle = new HostedPeerLifecycle(
@@ -777,7 +794,7 @@ async function startPeer(
777794
(reason) => {
778795
stream.peerClosed(reason);
779796
const connectionId = session.peer?.connectionId;
780-
onRetire(wrapped);
797+
onRetire(session.peer?.deviceId, wrapped);
781798
void session.connection?.close();
782799
try {
783800
native.close();
@@ -807,12 +824,14 @@ async function startPeer(
807824
});
808825
for (const label of CHANNELS) {
809826
const channel = channels[label]!;
827+
// A lane that never opened is still negotiating; only a lane that has
828+
// carried traffic and then left `open` proves this peer cannot deliver.
829+
let everOpened = false;
810830
const emitState = () => {
811-
stream.channelState(label, channel.readyState);
812-
applyHostedLaneDiagnostic(lifecycle, {
813-
channel: label,
814-
channelState: channel.readyState,
815-
});
831+
if (channel.readyState === 'open') everOpened = true;
832+
const hangup = requiredLaneClosed(label, channel.readyState, everOpened);
833+
stream.channelState(label, channel.readyState, hangup);
834+
if (hangup) lifecycle.fail(`WebRTC ${label} lane ${channel.readyState}.`);
816835
};
817836
channel.addEventListener('open', emitState);
818837
channel.addEventListener('close', emitState);
@@ -1177,7 +1196,11 @@ function bindControl(
11771196
deviceName: device?.deviceName?.trim() || 'Browser',
11781197
});
11791198
onApplication(connection, peer);
1199+
// The application lane closing ends this generation. Releasing the
1200+
// server connection here frees exactly this connection's attachments;
1201+
// the peer-level required-lane handler retires the peer itself.
11801202
application.addEventListener('close', () => {
1203+
void connection.close();
11811204
context.options.onPeerDisconnected?.(peer.connectionId);
11821205
});
11831206
void connection.start().catch((error) => {

0 commit comments

Comments
 (0)