@blue-soda/dsh-remote 0.4.29 → 0.4.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client-runtime.d.ts +137 -1
- package/dist/client-runtime.d.ts.map +1 -1
- package/dist/client-runtime.js +399 -22
- package/dist/client-runtime.js.map +1 -1
- package/dist/client-secure-transport.d.ts.map +1 -1
- package/dist/client-secure-transport.js +2 -1
- package/dist/client-secure-transport.js.map +1 -1
- package/dist/client-target-store.d.ts +40 -0
- package/dist/client-target-store.d.ts.map +1 -0
- package/dist/client-target-store.js +79 -0
- package/dist/client-target-store.js.map +1 -0
- package/dist/client.github.js +63 -15
- package/dist/client.js +63 -15
- package/dist/codex/domain.d.ts +9 -0
- package/dist/codex/domain.d.ts.map +1 -1
- package/dist/codex/domain.js +48 -7
- package/dist/codex/domain.js.map +1 -1
- package/dist/codex/virtual-harness.d.ts.map +1 -1
- package/dist/codex/virtual-harness.js +35 -1
- package/dist/codex/virtual-harness.js.map +1 -1
- package/dist/harness-api-bridge.d.ts +2 -0
- package/dist/harness-api-bridge.d.ts.map +1 -1
- package/dist/harness-api-bridge.js +10 -0
- package/dist/harness-api-bridge.js.map +1 -1
- package/dist/harness-remote-bridge.d.ts +8 -0
- package/dist/harness-remote-bridge.d.ts.map +1 -1
- package/dist/harness-remote-bridge.js +12 -1
- package/dist/harness-remote-bridge.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +795 -184
- package/dist/index.js.map +4 -4
- package/dist/remote-api-proxy.d.ts.map +1 -1
- package/dist/remote-api-proxy.js +52 -1
- package/dist/remote-api-proxy.js.map +1 -1
- package/dist/remote-typert-gateway.d.ts.map +1 -1
- package/dist/remote-typert-gateway.js +5 -3
- package/dist/remote-typert-gateway.js.map +1 -1
- package/dist/rpc-binary-attachments.d.ts +33 -0
- package/dist/rpc-binary-attachments.d.ts.map +1 -0
- package/dist/rpc-binary-attachments.js +142 -0
- package/dist/rpc-binary-attachments.js.map +1 -0
- package/dist/service.d.ts +10 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +17 -1
- package/dist/service.js.map +1 -1
- package/dist/tui-command.js +7 -5
- package/dist/tui-command.js.map +1 -1
- package/dist/typert-gateway-switch.d.ts.map +1 -1
- package/dist/typert-gateway-switch.js +148 -15
- package/dist/typert-gateway-switch.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dsh-plugin.json +1 -1
- package/package.json +1 -1
package/dist/client-runtime.js
CHANGED
|
@@ -24,6 +24,62 @@ const HOST_AUTHORIZATION_ERRORS = new Set([
|
|
|
24
24
|
'DEVICE_REVOKED',
|
|
25
25
|
'TOKEN_EXPIRED',
|
|
26
26
|
]);
|
|
27
|
+
/**
|
|
28
|
+
* Failures a retry loop cannot resolve on its own.
|
|
29
|
+
*
|
|
30
|
+
* The Account authorization has to be redone by the user, so retrying only burns attempts and
|
|
31
|
+
* would keep a boot restore alive forever on a device that is no longer signed in.
|
|
32
|
+
*/
|
|
33
|
+
const CREDENTIAL_FAILURE_CODES = new Set([
|
|
34
|
+
'AUTH_REQUIRED',
|
|
35
|
+
'ACCOUNT_AUTH_REQUIRED',
|
|
36
|
+
'AUTH_INVALID',
|
|
37
|
+
'TOKEN_EXPIRED',
|
|
38
|
+
'DEVICE_NOT_FOUND',
|
|
39
|
+
'DEVICE_REVOKED',
|
|
40
|
+
'MEMBERSHIP_REQUIRED',
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* How often an idle remote session proves its link is still there.
|
|
44
|
+
*
|
|
45
|
+
* A socket can end without either side being told: the peer is reaped, a NAT mapping expires, or a
|
|
46
|
+
* network path black-holes the close. A client that never notices keeps rendering a remote session
|
|
47
|
+
* that silently answers nothing, so it asks for one cheap answer on a fixed cadence.
|
|
48
|
+
*/
|
|
49
|
+
const LIVENESS_INTERVAL_MS = 30_000;
|
|
50
|
+
/** Cadence while the link is being rebuilt, so the next verdict arrives in seconds. */
|
|
51
|
+
const FAST_RECONNECT_INTERVAL_MS = 5_000;
|
|
52
|
+
/**
|
|
53
|
+
* How long one proof may take before it counts as no answer at all.
|
|
54
|
+
*
|
|
55
|
+
* The check is a cheap read-only call over a link that is already established, so a healthy peer
|
|
56
|
+
* answers in well under a second; a longer budget only postpones the verdict.
|
|
57
|
+
*/
|
|
58
|
+
const LIVENESS_TIMEOUT_MS = 4_000;
|
|
59
|
+
/** Unanswered proofs before giving up: the first rebuilds the link, the second falls back. */
|
|
60
|
+
const LIVENESS_TOLERATED_FAILURES = 2;
|
|
61
|
+
/** Codes the client core raises locally when an RPC never reached an answer. */
|
|
62
|
+
const NO_ANSWER_CODES = new Set(['RPC_TIMEOUT', 'CLIENT_CLOSED', 'TRANSPORT_CLOSED', 'RPC_ABORTED']);
|
|
63
|
+
/**
|
|
64
|
+
* Whether a proof of life failed to reach the peer.
|
|
65
|
+
*
|
|
66
|
+
* The check asks for an answer, not for success, so the two outcomes are told apart by where the
|
|
67
|
+
* error came from. An answer arrives as an error carrying the peer's own code (METHOD_NOT_FOUND,
|
|
68
|
+
* FEATURE_NOT_SUPPORTED, ...). A local failure is either one of the core's own codes above or a raw
|
|
69
|
+
* transport error with no code at all - a send that failed on a socket this process has not noticed
|
|
70
|
+
* is closed. Counting that second kind as liveness would make the whole check useless exactly when
|
|
71
|
+
* it matters.
|
|
72
|
+
* @param error - the error the check rejected with.
|
|
73
|
+
* @returns true when no answer arrived, so the transport must be treated as lost.
|
|
74
|
+
*/
|
|
75
|
+
export function livenessProbeLost(error) {
|
|
76
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
77
|
+
? error.code
|
|
78
|
+
: undefined;
|
|
79
|
+
if (typeof code !== 'string')
|
|
80
|
+
return true;
|
|
81
|
+
return NO_ANSWER_CODES.has(code);
|
|
82
|
+
}
|
|
27
83
|
export class ClientModeRuntime {
|
|
28
84
|
config;
|
|
29
85
|
identities;
|
|
@@ -31,6 +87,7 @@ export class ClientModeRuntime {
|
|
|
31
87
|
logger;
|
|
32
88
|
host;
|
|
33
89
|
rtcFactoryProvider;
|
|
90
|
+
targetStore;
|
|
34
91
|
preview;
|
|
35
92
|
identity;
|
|
36
93
|
connected;
|
|
@@ -51,20 +108,65 @@ export class ClientModeRuntime {
|
|
|
51
108
|
*/
|
|
52
109
|
remoteReconnectRun = 0;
|
|
53
110
|
pendingWorkspaceSelection;
|
|
111
|
+
/**
|
|
112
|
+
* Transport a running fast reconnect is replacing.
|
|
113
|
+
*
|
|
114
|
+
* The rebuild opens its own control connection and the Server closes the older one for the same
|
|
115
|
+
* device, so that close belongs to our own replacement. Reading it as a lost peer is what made the
|
|
116
|
+
* fast level escalate itself into the fallback before it could ever succeed.
|
|
117
|
+
*/
|
|
118
|
+
supersededClient;
|
|
119
|
+
/**
|
|
120
|
+
* True while a fast reconnect is rebuilding the link.
|
|
121
|
+
*
|
|
122
|
+
* The probe must not judge the link in the middle of its own replacement: the Server closes the
|
|
123
|
+
* older connection for the device, so a check that ran then would read our replacement's side effect
|
|
124
|
+
* as a second miss and escalate the level that is busy recovering. The rebuild's own outcome decides
|
|
125
|
+
* first; only once it has settled does the cadence resume judging.
|
|
126
|
+
*/
|
|
127
|
+
fastRebuildInFlight = false;
|
|
128
|
+
/**
|
|
129
|
+
* The last workspace the user opened for a Host.
|
|
130
|
+
*
|
|
131
|
+
* Kept so a reconnect can republish it: re-selecting the workspace is what makes the native UI
|
|
132
|
+
* re-read its session list, and without it a list poisoned by the outage stays wrong even after
|
|
133
|
+
* the link is back.
|
|
134
|
+
*/
|
|
135
|
+
lastWorkspaceSelection;
|
|
54
136
|
codexVirtual;
|
|
55
137
|
proxySwitch;
|
|
56
138
|
gatewaySwitch;
|
|
57
139
|
codexStreams = new Map();
|
|
58
140
|
connectionProgress;
|
|
59
141
|
connectionProgressRun = 0;
|
|
142
|
+
/**
|
|
143
|
+
* Host a boot is trying to restore, reported to the UI while the retry loop runs.
|
|
144
|
+
*
|
|
145
|
+
* Nothing is connected yet, so without this the window would show the local shell while the
|
|
146
|
+
* user is still looking at the remote workspace they left.
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* Recovery in progress, and which kind.
|
|
150
|
+
*
|
|
151
|
+
* `restore` is a start that is reconnecting to the recorded target, `fast` keeps the session on
|
|
152
|
+
* screen while its link is rebuilt, and `fallback` is the last resort that returns the user to
|
|
153
|
+
* the local shell. The UI shows all three, so a reconnect is never invisible, and the retry loop
|
|
154
|
+
* stops as soon as the user asks for local.
|
|
155
|
+
*/
|
|
156
|
+
reconnecting;
|
|
157
|
+
/** Periodic proof of life for the live remote session; absent while nothing is connected. */
|
|
158
|
+
livenessTimer;
|
|
159
|
+
livenessInFlight = false;
|
|
160
|
+
livenessFailures = 0;
|
|
60
161
|
closed = false;
|
|
61
|
-
constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory) {
|
|
162
|
+
constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory, targetStore) {
|
|
62
163
|
this.config = config;
|
|
63
164
|
this.identities = identities;
|
|
64
165
|
this.server = server;
|
|
65
166
|
this.logger = logger;
|
|
66
167
|
this.host = host;
|
|
67
168
|
this.rtcFactoryProvider = rtcFactoryProvider;
|
|
169
|
+
this.targetStore = targetStore;
|
|
68
170
|
this.proxySwitch = apiProxy === undefined ? undefined : new ApiProxySwitch(apiProxy);
|
|
69
171
|
this.gatewaySwitch = new TypertGatewaySwitch(typertGateway);
|
|
70
172
|
// Serve the local shell while no peer session is live. Otherwise a remote-mode
|
|
@@ -152,6 +254,7 @@ export class ClientModeRuntime {
|
|
|
152
254
|
...targetStatus,
|
|
153
255
|
connected: this.connected !== undefined,
|
|
154
256
|
fellBackToLocal: this.fellBackToLocal,
|
|
257
|
+
...(this.reconnecting === undefined ? {} : { reconnecting: { ...this.reconnecting } }),
|
|
155
258
|
transport: this.connected?.client.getStats().mode ?? 'Disconnected',
|
|
156
259
|
connectedTargetDeviceId: this.connected?.target.deviceId,
|
|
157
260
|
preferredTransports: this.config.forceRelay ? ['relay'] : ['lan', 'p2p', 'turn', 'relay'],
|
|
@@ -318,6 +421,8 @@ export class ClientModeRuntime {
|
|
|
318
421
|
this.connected = undefined;
|
|
319
422
|
this.connectionProgress = undefined;
|
|
320
423
|
this.pendingWorkspaceSelection = undefined;
|
|
424
|
+
// The user asked for local, so the next remote session starts from a fresh workspace choice.
|
|
425
|
+
this.lastWorkspaceSelection = undefined;
|
|
321
426
|
await this.closeCodexStreams(previous?.client);
|
|
322
427
|
await previous?.client.close().catch(() => undefined);
|
|
323
428
|
// Returning to local is the answer to a dropped session, so the record of
|
|
@@ -325,7 +430,10 @@ export class ClientModeRuntime {
|
|
|
325
430
|
// screen after the user has already acted on them. Stop any pending
|
|
326
431
|
// reconnect too — the user asked for local, not for a retry.
|
|
327
432
|
this.fellBackToLocal = false;
|
|
433
|
+
this.reconnecting = undefined;
|
|
434
|
+
this.stopLivenessWatch();
|
|
328
435
|
this.remoteReconnectRun += 1;
|
|
436
|
+
await this.rememberTarget({ mode: 'local' });
|
|
329
437
|
this.logger.info('Harness target switched', { mode: 'local' });
|
|
330
438
|
return this.status();
|
|
331
439
|
}
|
|
@@ -350,11 +458,211 @@ export class ClientModeRuntime {
|
|
|
350
458
|
this.selectRemoteTarget(next);
|
|
351
459
|
// A fresh remote session clears the record of an earlier dropped one.
|
|
352
460
|
this.fellBackToLocal = false;
|
|
461
|
+
this.reconnecting = undefined;
|
|
462
|
+
await this.rememberTarget({ mode: 'remote', hostDeviceId: next.target.deviceId, hostName: next.target.name });
|
|
353
463
|
await this.closeCodexStreams(previous?.client);
|
|
354
464
|
await previous?.client.close().catch(() => undefined);
|
|
355
465
|
this.logger.info('Harness target switched', { mode: 'remote', targetDeviceId: shortId(next.target.deviceId) });
|
|
356
466
|
return this.status();
|
|
357
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Prove the live remote link still answers, and act on the result.
|
|
470
|
+
*
|
|
471
|
+
* Only a missing answer counts as loss: the peer's own error (a refusal, an unknown method) came
|
|
472
|
+
* back over the same link and therefore proves it is there.
|
|
473
|
+
*
|
|
474
|
+
* The two recovery levels differ in what the user keeps. The first miss rebuilds the link in
|
|
475
|
+
* place, so the session, its workspace selection and the remote carriers all stay; the second
|
|
476
|
+
* gives up and returns to the local shell.
|
|
477
|
+
*/
|
|
478
|
+
async verifyRemoteLiveness() {
|
|
479
|
+
const connected = this.connected;
|
|
480
|
+
if (connected === undefined || this.livenessInFlight || this.fastRebuildInFlight)
|
|
481
|
+
return;
|
|
482
|
+
this.livenessInFlight = true;
|
|
483
|
+
try {
|
|
484
|
+
await connected.client.rpc('harness.transport.describe', {}, undefined, { timeoutMs: LIVENESS_TIMEOUT_MS });
|
|
485
|
+
this.livenessFailures = 0;
|
|
486
|
+
if (this.reconnecting?.phase === 'fast')
|
|
487
|
+
this.finishReconnect('the link answered again');
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (!livenessProbeLost(error)) {
|
|
491
|
+
// Any answer proves the peer is alive, including a refusal.
|
|
492
|
+
this.livenessFailures = 0;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
this.livenessFailures += 1;
|
|
496
|
+
this.logger.warn('remote Harness liveness check found no answer', {
|
|
497
|
+
targetDeviceId: shortId(connected.target.deviceId),
|
|
498
|
+
attempt: this.livenessFailures,
|
|
499
|
+
});
|
|
500
|
+
if (this.livenessFailures === 1) {
|
|
501
|
+
await this.enterFastReconnect(connected.target.deviceId, connected.target.name);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (this.livenessFailures >= LIVENESS_TOLERATED_FAILURES) {
|
|
505
|
+
this.handleRemoteTransportLost(connected.client, connected.target.deviceId, 'unanswered-twice');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
this.livenessInFlight = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Rebuild the link while the session stays on screen.
|
|
514
|
+
*
|
|
515
|
+
* This is what separates the two recovery levels: nothing is handed back to the local shell, so a
|
|
516
|
+
* link that recovers does not cost the user the view they were working in.
|
|
517
|
+
* @param targetDeviceId - the Host to rebuild the link to.
|
|
518
|
+
*/
|
|
519
|
+
async enterFastReconnect(targetDeviceId, targetName) {
|
|
520
|
+
if (this.reconnecting !== undefined)
|
|
521
|
+
return;
|
|
522
|
+
this.reconnecting = { targetDeviceId, ...(targetName === undefined ? {} : { targetName }), phase: 'fast' };
|
|
523
|
+
this.logger.warn('remote Harness link stopped answering; reconnecting in place', {
|
|
524
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
525
|
+
});
|
|
526
|
+
// Faster probes while recovering: the next miss is what decides recovery against the fallback.
|
|
527
|
+
this.armLivenessWatch(FAST_RECONNECT_INTERVAL_MS);
|
|
528
|
+
await this.reestablish(targetDeviceId);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Build a new transport for the session already on screen.
|
|
532
|
+
*
|
|
533
|
+
* The carriers hold the previous client, so they are rebound to the new one before the old client
|
|
534
|
+
* is closed; leaving it open would keep pointing remote calls at a dead transport.
|
|
535
|
+
* @param targetDeviceId - the Host to reconnect to.
|
|
536
|
+
* @returns true when a new session is in place.
|
|
537
|
+
*/
|
|
538
|
+
async reestablish(targetDeviceId) {
|
|
539
|
+
const previous = this.connected;
|
|
540
|
+
// From here until the new session is in place, a close of the old transport is our own doing and
|
|
541
|
+
// the liveness cadence stops judging the link.
|
|
542
|
+
this.supersededClient = previous?.client;
|
|
543
|
+
this.fastRebuildInFlight = true;
|
|
544
|
+
try {
|
|
545
|
+
const next = await this.connect(targetDeviceId);
|
|
546
|
+
if (previous === undefined) {
|
|
547
|
+
// The user asked for local while this attempt was running.
|
|
548
|
+
this.supersededClient = undefined;
|
|
549
|
+
await next.client.close().catch(() => undefined);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
await this.closePreview();
|
|
553
|
+
this.connected = next;
|
|
554
|
+
this.clearConnectionProgress(next.progressRunId);
|
|
555
|
+
this.selectRemoteTarget(next);
|
|
556
|
+
await this.closeCodexStreams(previous.client);
|
|
557
|
+
await previous.client.close().catch(() => undefined);
|
|
558
|
+
this.supersededClient = undefined;
|
|
559
|
+
this.finishReconnect('link re-established');
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
this.supersededClient = undefined;
|
|
564
|
+
this.logger.warn('fast reconnect attempt failed', {
|
|
565
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
566
|
+
code: safeErrorCode(error),
|
|
567
|
+
});
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
this.fastRebuildInFlight = false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
rememberWorkspaceSelection(selection) {
|
|
575
|
+
this.pendingWorkspaceSelection = selection;
|
|
576
|
+
this.lastWorkspaceSelection = { ...selection };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Republish the workspace selection so the native UI re-reads its remote session list.
|
|
580
|
+
*
|
|
581
|
+
* The client half consumes status.workspaceSelection and reconnects that workspace; that refresh is
|
|
582
|
+
* what replaces a list the outage had filled with local answers.
|
|
583
|
+
* @param targetDeviceId - the Host the reconnect finished against.
|
|
584
|
+
*/
|
|
585
|
+
restoreWorkspaceSelection(targetDeviceId) {
|
|
586
|
+
const selection = this.lastWorkspaceSelection;
|
|
587
|
+
if (selection === undefined || selection.targetDeviceId !== targetDeviceId)
|
|
588
|
+
return;
|
|
589
|
+
this.pendingWorkspaceSelection = { ...selection };
|
|
590
|
+
this.logger.info('republishing the workspace selection to re-read the remote session list', {
|
|
591
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
592
|
+
workspaceId: selection.workspaceId,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
finishReconnect(reason) {
|
|
596
|
+
const target = this.reconnecting?.targetDeviceId;
|
|
597
|
+
if (target === undefined)
|
|
598
|
+
return;
|
|
599
|
+
this.reconnecting = undefined;
|
|
600
|
+
this.livenessFailures = 0;
|
|
601
|
+
this.armLivenessWatch(LIVENESS_INTERVAL_MS);
|
|
602
|
+
this.restoreWorkspaceSelection(target);
|
|
603
|
+
this.logger.info('remote Harness reconnect finished', { targetDeviceId: shortId(target), reason });
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Check the live session now, outside the cadence.
|
|
607
|
+
*
|
|
608
|
+
* Used when the page becomes visible again, which is when a suspended client is most likely to be
|
|
609
|
+
* holding a link that already ended.
|
|
610
|
+
* @returns the status after the check.
|
|
611
|
+
*/
|
|
612
|
+
async verifyRemoteConnection() {
|
|
613
|
+
await this.verifyRemoteLiveness();
|
|
614
|
+
return this.status();
|
|
615
|
+
}
|
|
616
|
+
armLivenessWatch(intervalMs) {
|
|
617
|
+
if (this.livenessTimer !== undefined)
|
|
618
|
+
clearInterval(this.livenessTimer);
|
|
619
|
+
// The tick reads the live session, so a reconnected client is checked without re-arming.
|
|
620
|
+
const timer = setInterval(() => { void this.verifyRemoteLiveness(); }, intervalMs);
|
|
621
|
+
// A background check must never hold the process - or a test run - open.
|
|
622
|
+
timer.unref?.();
|
|
623
|
+
this.livenessTimer = timer;
|
|
624
|
+
}
|
|
625
|
+
stopLivenessWatch() {
|
|
626
|
+
if (this.livenessTimer !== undefined)
|
|
627
|
+
clearInterval(this.livenessTimer);
|
|
628
|
+
this.livenessTimer = undefined;
|
|
629
|
+
this.livenessFailures = 0;
|
|
630
|
+
this.livenessInFlight = false;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Shared cleanup for a session whose transport is gone.
|
|
634
|
+
*
|
|
635
|
+
* A close event and an exhausted liveness check must leave exactly the same state behind, so both
|
|
636
|
+
* paths run this. The session is gone for good here, which is why the phase becomes 'fallback' and
|
|
637
|
+
* the retry loop keeps the UI saying that it is reconnecting.
|
|
638
|
+
* @param client - the client that was connected.
|
|
639
|
+
* @param targetDeviceId - the Host it was bound to.
|
|
640
|
+
*/
|
|
641
|
+
handleRemoteTransportLost(client, targetDeviceId, reason) {
|
|
642
|
+
if (this.connected?.client !== client)
|
|
643
|
+
return;
|
|
644
|
+
this.supersededClient = undefined;
|
|
645
|
+
const targetName = this.connected.target.name;
|
|
646
|
+
// A closed transport is the disaster fallback: the session is gone and the retry loop takes over.
|
|
647
|
+
// Only an unanswered liveness check rebuilds in place first, so a peer that merely went quiet
|
|
648
|
+
// keeps the user's view while its link is re-established.
|
|
649
|
+
this.stopLivenessWatch();
|
|
650
|
+
void this.closePreview();
|
|
651
|
+
this.connected = undefined;
|
|
652
|
+
this.connectionProgress = undefined;
|
|
653
|
+
this.pendingWorkspaceSelection = undefined;
|
|
654
|
+
void this.closeCodexVirtual();
|
|
655
|
+
this.proxySwitch?.selectLocal();
|
|
656
|
+
this.gatewaySwitch.selectLocal();
|
|
657
|
+
this.fellBackToLocal = true;
|
|
658
|
+
this.reconnecting = { targetDeviceId, ...(targetName === undefined ? {} : { targetName }), phase: 'fallback' };
|
|
659
|
+
void client.close().catch(() => undefined);
|
|
660
|
+
this.logger.warn('remote Harness transport lost; reconnecting', {
|
|
661
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
662
|
+
reason,
|
|
663
|
+
});
|
|
664
|
+
void this.reconnectRemoteSession(targetDeviceId);
|
|
665
|
+
}
|
|
358
666
|
/**
|
|
359
667
|
* Re-establish a remote session whose transport closed.
|
|
360
668
|
*
|
|
@@ -383,23 +691,88 @@ export class ClientModeRuntime {
|
|
|
383
691
|
return;
|
|
384
692
|
try {
|
|
385
693
|
await this.setMode('remote', targetDeviceId);
|
|
694
|
+
this.restoreWorkspaceSelection(targetDeviceId);
|
|
386
695
|
this.logger.info('remote Harness session reconnected', { targetDeviceId: shortId(targetDeviceId) });
|
|
387
696
|
return;
|
|
388
697
|
}
|
|
389
698
|
catch (error) {
|
|
699
|
+
const code = safeErrorCode(error);
|
|
700
|
+
// Credentials that are missing or refused cannot be fixed by waiting, and a boot restore
|
|
701
|
+
// would otherwise retry an unauthorized connection forever. Stop and let the user sign in.
|
|
702
|
+
if (code !== undefined && CREDENTIAL_FAILURE_CODES.has(code)) {
|
|
703
|
+
this.logger.warn('remote Harness reconnect stopped: authorization is required', {
|
|
704
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
705
|
+
code,
|
|
706
|
+
});
|
|
707
|
+
this.reconnecting = undefined;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
390
710
|
// Report the early attempts, then only occasionally: a Host that stays
|
|
391
711
|
// away would otherwise fill the log every half minute.
|
|
392
712
|
if (attempt < 3 || attempt % 10 === 0) {
|
|
393
713
|
this.logger.warn('remote Harness reconnect attempt failed', {
|
|
394
714
|
targetDeviceId: shortId(targetDeviceId),
|
|
395
715
|
attempt,
|
|
396
|
-
code
|
|
716
|
+
code,
|
|
397
717
|
});
|
|
398
718
|
}
|
|
399
719
|
}
|
|
400
720
|
attempt += 1;
|
|
401
721
|
}
|
|
402
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Persist the target a later boot may have to restore.
|
|
725
|
+
*
|
|
726
|
+
* A failure here must not fail a connect: the only cost is that a killed process comes back to
|
|
727
|
+
* the local shell, which is where it would have been without this record.
|
|
728
|
+
* @param target - the target to record.
|
|
729
|
+
*/
|
|
730
|
+
async rememberTarget(target) {
|
|
731
|
+
if (this.targetStore === undefined)
|
|
732
|
+
return;
|
|
733
|
+
try {
|
|
734
|
+
await this.targetStore.save({
|
|
735
|
+
...target,
|
|
736
|
+
...(this.config.serverUrl === undefined ? {} : { serverUrl: this.config.serverUrl }),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
this.logger.warn('could not record the remote target', { code: safeErrorCode(error) });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Reconnect to the target this device was last using, with the usual backoff.
|
|
745
|
+
*
|
|
746
|
+
* A resumed app can miss the transport close entirely - Android may reclaim the process - so
|
|
747
|
+
* nothing would start the retry loop and the user would face a local shell behind a remote
|
|
748
|
+
* workspace view. Restoring the recorded target gives that case the same loop a dropped
|
|
749
|
+
* transport gets. A target recorded for another Server is left alone: it is not reachable
|
|
750
|
+
* through the configured one.
|
|
751
|
+
* @returns true when a retry loop was started.
|
|
752
|
+
*/
|
|
753
|
+
async restoreLastTarget() {
|
|
754
|
+
if (this.closed || this.connected !== undefined)
|
|
755
|
+
return false;
|
|
756
|
+
const record = await this.targetStore?.load();
|
|
757
|
+
if (record === undefined || record.mode !== 'remote' || record.hostDeviceId === undefined)
|
|
758
|
+
return false;
|
|
759
|
+
if (record.serverUrl !== undefined && record.serverUrl !== this.config.serverUrl) {
|
|
760
|
+
this.logger.info('recorded remote target belongs to another Server; not restoring', {
|
|
761
|
+
targetDeviceId: shortId(record.hostDeviceId),
|
|
762
|
+
});
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
this.reconnecting = {
|
|
766
|
+
targetDeviceId: record.hostDeviceId,
|
|
767
|
+
...(record.hostName === undefined ? {} : { targetName: record.hostName }),
|
|
768
|
+
phase: 'restore',
|
|
769
|
+
};
|
|
770
|
+
this.logger.info('restoring the remote target of the previous run', {
|
|
771
|
+
targetDeviceId: shortId(record.hostDeviceId),
|
|
772
|
+
});
|
|
773
|
+
void this.reconnectRemoteSession(record.hostDeviceId);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
403
776
|
async listRemoteDirectory(targetDeviceId, path, signal) {
|
|
404
777
|
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
405
778
|
if (remote.features.remoteGateway) {
|
|
@@ -456,7 +829,7 @@ export class ClientModeRuntime {
|
|
|
456
829
|
await this.closeCodexVirtual();
|
|
457
830
|
this.selectRemoteTarget(remote, transport);
|
|
458
831
|
const workspaceId = workspaceRecordId(workspace.workspace);
|
|
459
|
-
this.
|
|
832
|
+
this.rememberWorkspaceSelection({ targetDeviceId: remote.target.deviceId, workspaceId });
|
|
460
833
|
this.logger.info('Remote workspace opened', { targetDeviceId: shortId(remote.target.deviceId) });
|
|
461
834
|
return { ...this.status(), workspace };
|
|
462
835
|
}
|
|
@@ -491,12 +864,12 @@ export class ClientModeRuntime {
|
|
|
491
864
|
this.codexVirtual = virtual;
|
|
492
865
|
this.selectCodexTarget(virtual, remote);
|
|
493
866
|
const preferredSessionId = await virtual.preferredSessionId(signal);
|
|
494
|
-
this.
|
|
867
|
+
this.rememberWorkspaceSelection({
|
|
495
868
|
targetDeviceId: remote.target.deviceId,
|
|
496
869
|
workspaceId,
|
|
497
870
|
backend: 'codex',
|
|
498
871
|
...(preferredSessionId === undefined ? {} : { sessionId: preferredSessionId }),
|
|
499
|
-
};
|
|
872
|
+
});
|
|
500
873
|
this.logger.info('CodeX virtual workspace opened', { targetDeviceId: shortId(remote.target.deviceId) });
|
|
501
874
|
return { ...this.status(), workspace };
|
|
502
875
|
}
|
|
@@ -522,6 +895,10 @@ export class ClientModeRuntime {
|
|
|
522
895
|
return this.openCodexWorkspace(targetDeviceId, codexProjectWorkspaceId(project.id), signal);
|
|
523
896
|
}
|
|
524
897
|
consumeWorkspaceSelection(selection) {
|
|
898
|
+
// The client half opened this selection, so it is the workspace a later reconnect has to republish.
|
|
899
|
+
// It can arrive from the browser's own stored selection rather than from workspaces.open, which is
|
|
900
|
+
// why remembering it here - and not only where we publish it - is what makes the re-baseline work.
|
|
901
|
+
this.lastWorkspaceSelection = { ...selection };
|
|
525
902
|
const pending = this.pendingWorkspaceSelection;
|
|
526
903
|
if (pending?.targetDeviceId === selection.targetDeviceId
|
|
527
904
|
&& pending.workspaceId === selection.workspaceId
|
|
@@ -535,6 +912,7 @@ export class ClientModeRuntime {
|
|
|
535
912
|
if (this.closed)
|
|
536
913
|
return;
|
|
537
914
|
this.closed = true;
|
|
915
|
+
this.stopLivenessWatch();
|
|
538
916
|
this.proxySwitch?.selectLocal();
|
|
539
917
|
await this.closePreview();
|
|
540
918
|
this.gatewaySwitch.selectLocal();
|
|
@@ -854,25 +1232,19 @@ export class ClientModeRuntime {
|
|
|
854
1232
|
const connectedPreference = transportPreferenceForMode(connectedClient.getStats().mode);
|
|
855
1233
|
this.updateConnectionProgress(progressRunId, 'connected', connectedPreference === undefined ? undefined : [connectedPreference]);
|
|
856
1234
|
connectedClient.onClose(() => {
|
|
857
|
-
if (this.
|
|
1235
|
+
if (this.supersededClient === connectedClient) {
|
|
1236
|
+
this.logger.info('ignoring the close of the transport a reconnect is replacing', {
|
|
1237
|
+
targetDeviceId: shortId(target.deviceId),
|
|
1238
|
+
});
|
|
858
1239
|
return;
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
this
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
this.proxySwitch?.selectLocal();
|
|
865
|
-
this.gatewaySwitch.selectLocal();
|
|
866
|
-
// The UI keeps whatever the remote session rendered and offers no exit
|
|
867
|
-
// route once the mode reads 'local' again, so remember that the session
|
|
868
|
-
// dropped. The card uses this to keep a way back to the local shell.
|
|
869
|
-
this.fellBackToLocal = true;
|
|
870
|
-
void connectedClient.close().catch(() => undefined);
|
|
871
|
-
this.logger.warn('remote Harness transport closed; reconnecting', {
|
|
872
|
-
targetDeviceId: shortId(target.deviceId),
|
|
873
|
-
});
|
|
874
|
-
void this.reconnectRemoteSession(target.deviceId);
|
|
1240
|
+
}
|
|
1241
|
+
// The UI keeps whatever the remote session rendered and offers no exit route once the mode
|
|
1242
|
+
// reads 'local' again, so remember that the session dropped: the card uses this to keep a
|
|
1243
|
+
// way back to the local shell.
|
|
1244
|
+
this.handleRemoteTransportLost(connectedClient, target.deviceId, 'transport-closed');
|
|
875
1245
|
});
|
|
1246
|
+
// A live session proves itself on a cadence: a close event is not guaranteed to arrive.
|
|
1247
|
+
this.armLivenessWatch(LIVENESS_INTERVAL_MS);
|
|
876
1248
|
const connectionDetails = await connectedTransport.connectionDetails().catch(() => undefined);
|
|
877
1249
|
this.logger.info('remote Harness transport ready', {
|
|
878
1250
|
targetDeviceId: shortId(target.deviceId),
|
|
@@ -951,6 +1323,11 @@ export class ClientModeRuntime {
|
|
|
951
1323
|
try {
|
|
952
1324
|
if (endpoint === 'status')
|
|
953
1325
|
return ok(await this.detailedStatus());
|
|
1326
|
+
// A client that just came back to the foreground asks for an immediate check instead of
|
|
1327
|
+
// waiting for the next interval: while it was suspended it answered nothing and may have
|
|
1328
|
+
// missed the transport close entirely.
|
|
1329
|
+
if (endpoint === 'client.connection.verify')
|
|
1330
|
+
return ok(await this.verifyRemoteConnection());
|
|
954
1331
|
if (endpoint === 'devices')
|
|
955
1332
|
return ok(await this.devices());
|
|
956
1333
|
if (endpoint === 'client.account.login') {
|