dsh-ssh-tui 0.7.2 → 0.7.4
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/README.en.md +113 -27
- package/README.md +90 -27
- package/cordis.patch.yml +15 -0
- package/docs/remote-ops.md +27 -1
- package/docs/terminals.md +126 -0
- package/docs/windows.md +128 -0
- package/lib/attach.js +14 -1
- package/lib/attach.js.map +1 -1
- package/lib/commands.js +1 -0
- package/lib/commands.js.map +1 -1
- package/lib/copy-text.js +2 -0
- package/lib/copy-text.js.map +1 -1
- package/lib/diag.js +38 -0
- package/lib/diag.js.map +1 -1
- package/lib/dialogs.js.map +1 -1
- package/lib/display-sock.js +572 -44
- package/lib/display-sock.js.map +1 -1
- package/lib/doctor.js +71 -37
- package/lib/doctor.js.map +1 -1
- package/lib/dsh-compat.js +252 -88
- package/lib/dsh-compat.js.map +1 -1
- package/lib/footer.js +4 -2
- package/lib/footer.js.map +1 -1
- package/lib/i18n/en.js +54 -10
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +18 -7
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +54 -10
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +111 -26
- package/lib/index.js.map +1 -1
- package/lib/line-mode.js +4 -0
- package/lib/line-mode.js.map +1 -1
- package/lib/paint.js +37 -3
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +109 -18
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +8 -0
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +377 -11
- package/lib/platform.js.map +1 -1
- package/lib/preset-authoring.js +10 -14
- package/lib/preset-authoring.js.map +1 -1
- package/lib/preset-compat.js +100 -0
- package/lib/preset-compat.js.map +1 -0
- package/lib/preset-picker.js +5 -1
- package/lib/preset-picker.js.map +1 -1
- package/lib/preset-rows.js +119 -19
- package/lib/preset-rows.js.map +1 -1
- package/lib/provider-catalog.js +4 -4
- package/lib/question-wait.js +418 -0
- package/lib/question-wait.js.map +1 -0
- package/lib/route-memory.js +3 -3
- package/lib/route-memory.js.map +1 -1
- package/lib/selection.js +26 -8
- package/lib/selection.js.map +1 -1
- package/lib/session-index.js +5 -0
- package/lib/session-index.js.map +1 -1
- package/lib/session-lock.js +4 -1
- package/lib/session-lock.js.map +1 -1
- package/lib/session-route.js +3 -0
- package/lib/session-route.js.map +1 -1
- package/lib/settings-routes.js +10 -0
- package/lib/settings-routes.js.map +1 -0
- package/lib/settings-subagent.js +10 -0
- package/lib/settings-subagent.js.map +1 -0
- package/lib/subagent-model.js +5 -5
- package/lib/subagent-model.js.map +1 -1
- package/lib/supergrok-token.js +4 -0
- package/lib/supergrok-token.js.map +1 -1
- package/lib/term-text.js +79 -8
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-caps.js +358 -0
- package/lib/terminal-caps.js.map +1 -0
- package/lib/terminal-input.js +13 -0
- package/lib/terminal-input.js.map +1 -1
- package/lib/tui.js +775 -165
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +8 -0
- package/lib/types/commands.d.ts +3 -0
- package/lib/types/diag.d.ts +20 -1
- package/lib/types/dialogs.d.ts +16 -0
- package/lib/types/display-sock.d.ts +141 -24
- package/lib/types/doctor.d.ts +8 -1
- package/lib/types/dsh-compat.d.ts +173 -44
- package/lib/types/footer.d.ts +3 -1
- package/lib/types/i18n/index.d.ts +31 -15
- package/lib/types/index.d.ts +45 -0
- package/lib/types/paint.d.ts +21 -0
- package/lib/types/picker.d.ts +25 -0
- package/lib/types/platform.d.ts +197 -10
- package/lib/types/preset-authoring.d.ts +8 -14
- package/lib/types/preset-compat.d.ts +58 -0
- package/lib/types/preset-picker.d.ts +1 -1
- package/lib/types/preset-rows.d.ts +63 -13
- package/lib/types/question-wait.d.ts +221 -0
- package/lib/types/selection.d.ts +12 -4
- package/lib/types/settings-routes.d.ts +16 -0
- package/lib/types/settings-subagent.d.ts +24 -0
- package/lib/types/subagent-model.d.ts +10 -10
- package/lib/types/term-text.d.ts +14 -0
- package/lib/types/terminal-caps.d.ts +105 -0
- package/lib/types/terminal-input.d.ts +10 -0
- package/lib/types/transcript-types.d.ts +22 -0
- package/lib/types/tui.d.ts +167 -10
- package/lib/types/update-check.d.ts +19 -4
- package/lib/types/workspace-changes.d.ts +135 -0
- package/lib/update-check.js +25 -8
- package/lib/update-check.js.map +1 -1
- package/lib/workspace-changes.js +120 -0
- package/lib/workspace-changes.js.map +1 -0
- package/package.json +124 -59
package/lib/display-sock.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* and a named pipe at `\\.\pipe\dsh-tui-<home>-<id>` on Windows. Node requires
|
|
7
7
|
* the `\\.\pipe\` form there — a plain file path cannot be listened on — and
|
|
8
8
|
* `fs.access()` cannot see pipes, so channel liveness always goes through
|
|
9
|
-
* `displaySockExists()` instead of a raw filesystem check.
|
|
9
|
+
* `displaySockExists()` instead of a raw filesystem check. The POSIX name is
|
|
10
|
+
* budgeted against `sun_path` (107 bytes on Linux, 103 on macOS): a deep
|
|
11
|
+
* `DSH_HOME` shortens the readable label, it never fails `listen()`.
|
|
10
12
|
*
|
|
11
13
|
* Frame: u32be length | u8 type | payload
|
|
12
14
|
* 1 stdin — relay → host (key bytes)
|
|
@@ -16,16 +18,22 @@
|
|
|
16
18
|
* 5 goodbye — host → relay, then close (user /exit)
|
|
17
19
|
* 6 rtt — relay → host (u32be milliseconds; 0xffffffff = unknown)
|
|
18
20
|
* 7 replaced — host → relay, then close (a newer Display took the session)
|
|
21
|
+
* 8 probe — host → relay: round-trip your terminal and report it
|
|
22
|
+
* 9 probe? — relay → host: 1 live, 2 silent (answered before, not now), 0 unknown
|
|
23
|
+
* 10 query — any process → host: is a window really on this session?
|
|
24
|
+
* 11 query? — host → that process: 1 attached, 2 detached, 0 cannot tell
|
|
19
25
|
*/
|
|
20
|
-
import { spawn } from 'node:child_process';
|
|
26
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
27
|
import { StringDecoder } from 'node:string_decoder';
|
|
22
28
|
import { createHash } from 'node:crypto';
|
|
23
29
|
import { createConnection, createServer } from 'node:net';
|
|
24
30
|
import { mkdir, readFile, unlink } from 'node:fs/promises';
|
|
25
|
-
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
31
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
26
32
|
import { homedir } from 'node:os';
|
|
27
|
-
import { TerminalInputFilter, TerminalInputPump } from './terminal-input.js';
|
|
33
|
+
import { RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
|
|
28
34
|
import { dirname, join, resolve } from 'node:path';
|
|
35
|
+
import { bootstrapEnv, hostBootstrapCommand, hostSpawnOptions, restrictPathToUserSync, usesSigwinch, } from './platform.js';
|
|
36
|
+
import { terminalCapabilities } from './terminal-caps.js';
|
|
29
37
|
export const FRAME_STDIN = 1;
|
|
30
38
|
export const FRAME_STDOUT = 2;
|
|
31
39
|
export const FRAME_RESIZE = 3;
|
|
@@ -33,6 +41,14 @@ export const FRAME_HELLO = 4;
|
|
|
33
41
|
export const FRAME_GOODBYE = 5;
|
|
34
42
|
export const FRAME_RTT = 6;
|
|
35
43
|
export const FRAME_REPLACED = 7;
|
|
44
|
+
/** Host → relay: round-trip your terminal now and say what it answered. */
|
|
45
|
+
export const FRAME_PROBE = 8;
|
|
46
|
+
/** Relay → Host: the answer to {@link FRAME_PROBE}. */
|
|
47
|
+
export const FRAME_PROBE_REPLY = 9;
|
|
48
|
+
/** A prober → Host: is a window *really* on this session? Does not claim it. */
|
|
49
|
+
export const FRAME_QUERY = 10;
|
|
50
|
+
/** Host → prober: the answer to {@link FRAME_QUERY}. */
|
|
51
|
+
export const FRAME_QUERY_REPLY = 11;
|
|
36
52
|
const MAX_FRAME = 1024 * 1024;
|
|
37
53
|
/**
|
|
38
54
|
* Grace period for a kicked relay to read FRAME_REPLACED before its socket is
|
|
@@ -53,6 +69,40 @@ const REPLACED_GRACE_MS = 250;
|
|
|
53
69
|
* attach at all — hence the wider grace.
|
|
54
70
|
*/
|
|
55
71
|
const DISPLAY_HELLO_GRACE_MS = 6_000;
|
|
72
|
+
/**
|
|
73
|
+
* How long the Host waits for a relay's terminal round trip.
|
|
74
|
+
*
|
|
75
|
+
* The relay's own probe is two windows at most (a fast one, then a widened one
|
|
76
|
+
* for a slow link), so this only has to cover those plus the socket hop.
|
|
77
|
+
*/
|
|
78
|
+
const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
|
|
79
|
+
/**
|
|
80
|
+
* The link is measured again this soon after an attach, then on the slower
|
|
81
|
+
* cadence below.
|
|
82
|
+
*
|
|
83
|
+
* The first measurement is taken while the session is still booting, and a
|
|
84
|
+
* jittery moment there used to be the only measurement there ever was: the
|
|
85
|
+
* footer chip stayed red and the paint cadence — with the per-frame byte budget
|
|
86
|
+
* — stayed at the slowest tier for hours. The early re-check corrects exactly
|
|
87
|
+
* that case without waiting a full cadence.
|
|
88
|
+
*/
|
|
89
|
+
const RTT_RECHECK_FIRST_MS = 8_000;
|
|
90
|
+
/** How often an attached relay re-measures the link for the Host's chip. */
|
|
91
|
+
const RTT_RECHECK_MS = 20_000;
|
|
92
|
+
/** After this many silent measurements, stop asking so often. */
|
|
93
|
+
const RTT_RECHECK_MAX_MISSES = 3;
|
|
94
|
+
/** A terminal that does not answer DSR is asked again only this rarely. */
|
|
95
|
+
const RTT_RECHECK_BACKOFF_MS = 600_000;
|
|
96
|
+
/**
|
|
97
|
+
* A measurement that moved by at least this much is confirmed soon after.
|
|
98
|
+
*
|
|
99
|
+
* The Host smooths over the last few measurements, so the *second* sample is
|
|
100
|
+
* what makes a real change stick — or what retires a one-off spike — and waiting
|
|
101
|
+
* a whole cadence for it keeps the chip on the wrong tier for that long.
|
|
102
|
+
*/
|
|
103
|
+
const RTT_MOVED_MS = 50;
|
|
104
|
+
/** How soon a moved measurement is confirmed. */
|
|
105
|
+
const RTT_RECHECK_CONFIRM_MS = 5_000;
|
|
56
106
|
/**
|
|
57
107
|
* Drop launcher SIGTERM/SIGINT/SIGHUP so closing SSH cannot dispose the tree
|
|
58
108
|
* before hangup handling. Leaving the session with setsid() is best-effort:
|
|
@@ -82,8 +132,36 @@ export function isTuiHostProcess(env = process.env) {
|
|
|
82
132
|
* path fails with ENOENT/EACCES and the Host never listens.
|
|
83
133
|
*/
|
|
84
134
|
export const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\';
|
|
135
|
+
/**
|
|
136
|
+
* How long the hidden-console bootstrap may take to report the Host's pid. It
|
|
137
|
+
* covers a cold PowerShell start on a busy machine, and nothing of the Host's
|
|
138
|
+
* own boot: `Start-Process -PassThru` returns as soon as the process exists.
|
|
139
|
+
*/
|
|
140
|
+
const HOST_BOOTSTRAP_TIMEOUT_MS = 15_000;
|
|
85
141
|
/** Windows rejects pipe names longer than 256 chars; leave generous headroom. */
|
|
86
142
|
const WINDOWS_PIPE_MAX = 200;
|
|
143
|
+
/**
|
|
144
|
+
* `sockaddr_un.sun_path` counts its NUL terminator, so a POSIX socket address
|
|
145
|
+
* may occupy 107 bytes on Linux and 103 on macOS. Overrunning it fails
|
|
146
|
+
* `listen()` with EINVAL before the Host can print anything, which the launcher
|
|
147
|
+
* could only report as "the socket did not appear" — hence the budget below.
|
|
148
|
+
*/
|
|
149
|
+
function posixSocketPathMax(platform) {
|
|
150
|
+
return platform === 'darwin' ? 103 : 107;
|
|
151
|
+
}
|
|
152
|
+
/** Every socket name ends in this; it is never part of the head budget. */
|
|
153
|
+
const SOCK_SUFFIX = '.sock';
|
|
154
|
+
/**
|
|
155
|
+
* Longest readable head a socket name asks for. A home shallow enough to have
|
|
156
|
+
* room for it keeps exactly the name it had before the budget existed.
|
|
157
|
+
*/
|
|
158
|
+
const SOCK_LABEL_MAX = 80;
|
|
159
|
+
/**
|
|
160
|
+
* Shortest socket name worth emitting: a five-character head, the separator and
|
|
161
|
+
* the digest. Below this the head is noise and the address is refused with the
|
|
162
|
+
* real reason instead of being handed to `listen()` to fail on.
|
|
163
|
+
*/
|
|
164
|
+
const SOCK_LABEL_MIN = 14;
|
|
87
165
|
/** True for a Windows named-pipe address (`\\.\pipe\x`, `\\?\pipe\x`, `//./pipe/x`). */
|
|
88
166
|
export function isPipePath(path) {
|
|
89
167
|
const normalized = path.replaceAll('/', '\\').toLowerCase();
|
|
@@ -112,6 +190,13 @@ export function safeSessionId(sessionId) {
|
|
|
112
190
|
const safe = sessionId.replaceAll(/[^A-Za-z0-9._-]/g, '_');
|
|
113
191
|
return safe === '' ? 'session' : safe;
|
|
114
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Digest that keeps two sessions with collapsing names apart. It is appended to
|
|
195
|
+
* every label, however much head a tight path budget has to cut.
|
|
196
|
+
*/
|
|
197
|
+
function sessionDigest(sessionId) {
|
|
198
|
+
return createHash('sha1').update(sessionId).digest('hex').slice(0, 8);
|
|
199
|
+
}
|
|
115
200
|
/**
|
|
116
201
|
* Readable, collision-free short label for one session: a sanitized head plus a
|
|
117
202
|
* digest of the raw id. The digest is always appended because sanitizing alone
|
|
@@ -121,13 +206,36 @@ export function safeSessionId(sessionId) {
|
|
|
121
206
|
* the wrong session.
|
|
122
207
|
*/
|
|
123
208
|
export function sessionLabel(sessionId, maxLength) {
|
|
124
|
-
const digest =
|
|
209
|
+
const digest = sessionDigest(sessionId);
|
|
125
210
|
const head = safeSessionId(sessionId)
|
|
126
211
|
.replaceAll(/\.{2,}/gu, '_')
|
|
127
212
|
.replace(/^[._-]+|[._-]+$/gu, '')
|
|
128
213
|
.slice(0, Math.max(0, maxLength - digest.length - 1));
|
|
129
214
|
return `${head === '' ? 'session' : head}-${digest}`;
|
|
130
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* One POSIX socket *name* (the directory is prepended by the caller).
|
|
218
|
+
*
|
|
219
|
+
* The directory is measured in bytes, not characters: `sun_path` counts the
|
|
220
|
+
* encoded address, and a home with non-ASCII characters spends more than one
|
|
221
|
+
* byte per character. As the directory grows the readable head shrinks — a deep
|
|
222
|
+
* `DSH_HOME` loses the label, never the channel — while a home that used to fit
|
|
223
|
+
* keeps its old name byte for byte. A home too deep even for a short head is
|
|
224
|
+
* refused here, with the real reason, instead of by `listen()` EINVAL.
|
|
225
|
+
*/
|
|
226
|
+
function posixSocketName(sessionId, dir, platform) {
|
|
227
|
+
const limit = posixSocketPathMax(platform);
|
|
228
|
+
// The separator and the suffix are not negotiable; only the head is.
|
|
229
|
+
const room = limit - Buffer.byteLength(dir) - 1 - SOCK_SUFFIX.length;
|
|
230
|
+
if (room < SOCK_LABEL_MIN) {
|
|
231
|
+
throw new Error(`dsh-ssh-tui: ${dir} is too deep for a Unix socket: only ${Math.max(0, room)} of the ${limit} bytes `
|
|
232
|
+
+ `sun_path allows are left for the name, and a usable name needs ${SOCK_LABEL_MIN}. `
|
|
233
|
+
+ 'Point DSH_HOME at a shorter path.');
|
|
234
|
+
}
|
|
235
|
+
// Safe session ids are ASCII by construction (`safeSessionId`), so the label's
|
|
236
|
+
// length in characters is its length in bytes.
|
|
237
|
+
return `${sessionLabel(sessionId, Math.min(SOCK_LABEL_MAX, room))}${SOCK_SUFFIX}`;
|
|
238
|
+
}
|
|
131
239
|
/**
|
|
132
240
|
* Named pipes live in one flat, machine-wide namespace, so the DSH_HOME is
|
|
133
241
|
* folded into the name (two homes must not fight over one session id) and the
|
|
@@ -135,9 +243,12 @@ export function sessionLabel(sessionId, maxLength) {
|
|
|
135
243
|
* into collisions.
|
|
136
244
|
*/
|
|
137
245
|
function windowsPipePath(sessionId, dshHome) {
|
|
138
|
-
const homeTag =
|
|
139
|
-
|
|
140
|
-
|
|
246
|
+
const homeTag = sessionDigest(resolve(dshHome).toLowerCase());
|
|
247
|
+
// `\\.\pipe\` is a flat, machine-wide namespace with a 256-character name
|
|
248
|
+
// limit; WINDOWS_PIPE_MAX keeps the whole address well inside it, and the
|
|
249
|
+
// label's digest keeps two long ids apart however much head gets cut.
|
|
250
|
+
const head = `${WINDOWS_PIPE_PREFIX}dsh-tui-${homeTag}-`;
|
|
251
|
+
return `${head}${sessionLabel(sessionId, WINDOWS_PIPE_MAX - head.length)}`;
|
|
141
252
|
}
|
|
142
253
|
/**
|
|
143
254
|
* Address of the per-session display channel: a filesystem path on POSIX, a
|
|
@@ -147,8 +258,8 @@ function windowsPipePath(sessionId, dshHome) {
|
|
|
147
258
|
export function sessionSockPath(sessionId, dshHome = defaultDshHome(), platform = process.platform) {
|
|
148
259
|
if (platform === 'win32')
|
|
149
260
|
return windowsPipePath(sessionId, dshHome);
|
|
150
|
-
|
|
151
|
-
return join(
|
|
261
|
+
const dir = sessionSockDir(dshHome);
|
|
262
|
+
return join(dir, posixSocketName(sessionId, dir, platform));
|
|
152
263
|
}
|
|
153
264
|
/**
|
|
154
265
|
* Pre-digest POSIX socket path (`tui-socks/<safeId>.sock`).
|
|
@@ -188,6 +299,19 @@ export function sessionErrPath(sessionId, dshHome = defaultDshHome(), platform =
|
|
|
188
299
|
return join(sessionSockDir(dshHome), `${sessionLabel(sessionId, 64)}.err`);
|
|
189
300
|
return `${sock}.err`;
|
|
190
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Where the hidden-console bootstrap writes the Host's pid.
|
|
304
|
+
*
|
|
305
|
+
* On `\\.\pipe\` Windows there is no socket file to derive a name from, and the
|
|
306
|
+
* state directory already holds the per-session lock and stderr log, so the pid
|
|
307
|
+
* file lives beside them. It is removed as soon as it has been read.
|
|
308
|
+
*/
|
|
309
|
+
export function sessionBootstrapPidPath(sessionId, dshHome = defaultDshHome()) {
|
|
310
|
+
// Same label budget as the stderr log next to it: these live in the state
|
|
311
|
+
// directory rather than next to a socket file, and a long name here is
|
|
312
|
+
// MAX_PATH budget spent for nothing.
|
|
313
|
+
return join(sessionSockDir(dshHome), `${sessionLabel(sessionId, 64)}.boot.pid`);
|
|
314
|
+
}
|
|
191
315
|
/** Pre-digest Host stderr log next to the 0.7.1 socket, when that name differs. */
|
|
192
316
|
export function legacySessionErrPath(sessionId, dshHome = defaultDshHome(), platform = process.platform) {
|
|
193
317
|
const legacy = legacySessionSockPath(sessionId, dshHome, platform);
|
|
@@ -229,6 +353,33 @@ export function decodeRtt(payload) {
|
|
|
229
353
|
const value = payload.readUInt32BE(0);
|
|
230
354
|
return value === 0xffffffff ? undefined : value;
|
|
231
355
|
}
|
|
356
|
+
/** One byte of verdict: 1 live, 2 silent, 0 anything else (unknown). */
|
|
357
|
+
function encodeVerdict(type, value) {
|
|
358
|
+
return encodeFrame(type, Buffer.from([value]));
|
|
359
|
+
}
|
|
360
|
+
function decodeVerdict(payload) {
|
|
361
|
+
return payload.length > 0 ? payload.readUInt8(0) : 0;
|
|
362
|
+
}
|
|
363
|
+
export function encodeProbe() {
|
|
364
|
+
return encodeFrame(FRAME_PROBE);
|
|
365
|
+
}
|
|
366
|
+
export function encodeProbeReply(verdict) {
|
|
367
|
+
return encodeVerdict(FRAME_PROBE_REPLY, verdict === 'live' ? 1 : verdict === 'silent' ? 2 : 0);
|
|
368
|
+
}
|
|
369
|
+
export function decodeProbeReply(payload) {
|
|
370
|
+
const value = decodeVerdict(payload);
|
|
371
|
+
return value === 1 ? 'live' : value === 2 ? 'silent' : 'unknown';
|
|
372
|
+
}
|
|
373
|
+
export function encodeQuery() {
|
|
374
|
+
return encodeFrame(FRAME_QUERY);
|
|
375
|
+
}
|
|
376
|
+
export function encodeQueryReply(verdict) {
|
|
377
|
+
return encodeVerdict(FRAME_QUERY_REPLY, verdict === 'attached' ? 1 : verdict === 'detached' ? 2 : 0);
|
|
378
|
+
}
|
|
379
|
+
export function decodeQueryReply(payload) {
|
|
380
|
+
const value = decodeVerdict(payload);
|
|
381
|
+
return value === 1 ? 'attached' : value === 2 ? 'detached' : 'unknown';
|
|
382
|
+
}
|
|
232
383
|
/** Incremental decoder for one socket. */
|
|
233
384
|
export class FrameReader {
|
|
234
385
|
buffer = Buffer.alloc(0);
|
|
@@ -276,8 +427,12 @@ export class DisplayHost {
|
|
|
276
427
|
socket;
|
|
277
428
|
reader = new FrameReader();
|
|
278
429
|
attached = false;
|
|
430
|
+
/** Set while a {@link FRAME_PROBE} round trip is waiting for its answer. */
|
|
431
|
+
probeSettle;
|
|
432
|
+
probeInFlight;
|
|
279
433
|
constructor(path, handlers,
|
|
280
|
-
/** Test
|
|
434
|
+
/** Test seams: how long a silent connection may wait for its HELLO, and
|
|
435
|
+
* how long a relay may take to report its terminal round trip. */
|
|
281
436
|
options = {}) {
|
|
282
437
|
this.path = path;
|
|
283
438
|
this.handlers = handlers;
|
|
@@ -404,6 +559,12 @@ export class DisplayHost {
|
|
|
404
559
|
for (const frame of frames) {
|
|
405
560
|
if (frame.type === FRAME_HELLO)
|
|
406
561
|
claim();
|
|
562
|
+
else if (frame.type === FRAME_QUERY) {
|
|
563
|
+
// A prober asking whether a window is really on this session. It is
|
|
564
|
+
// deliberately not a claim: answering must never steal the display
|
|
565
|
+
// from the window the question is about.
|
|
566
|
+
void this.answerQuery(socket);
|
|
567
|
+
}
|
|
407
568
|
else if (!claimed)
|
|
408
569
|
continue;
|
|
409
570
|
else if (frame.type === FRAME_STDIN)
|
|
@@ -416,6 +577,9 @@ export class DisplayHost {
|
|
|
416
577
|
else if (frame.type === FRAME_RTT) {
|
|
417
578
|
this.handlers.onRtt?.(decodeRtt(frame.payload));
|
|
418
579
|
}
|
|
580
|
+
else if (frame.type === FRAME_PROBE_REPLY) {
|
|
581
|
+
this.settleProbe(decodeProbeReply(frame.payload));
|
|
582
|
+
}
|
|
419
583
|
}
|
|
420
584
|
});
|
|
421
585
|
socket.on('close', () => {
|
|
@@ -432,6 +596,79 @@ export class DisplayHost {
|
|
|
432
596
|
const probeGrace = setTimeout(dropProbe, this.options.helloGraceMs ?? DISPLAY_HELLO_GRACE_MS);
|
|
433
597
|
probeGrace.unref?.();
|
|
434
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Whether a window is really on this session — asked of the window itself.
|
|
601
|
+
*
|
|
602
|
+
* "The socket answers" is not evidence. A relay whose SSH link was cut stays
|
|
603
|
+
* connected (its launcher sees no hangup until sshd does), and the Host then
|
|
604
|
+
* truthfully reports a display that no longer has a screen behind it. Only the
|
|
605
|
+
* far end can settle it, so this round-trips the terminal through the relay.
|
|
606
|
+
*/
|
|
607
|
+
async attachmentVerdict() {
|
|
608
|
+
if (this.socket === undefined || this.attached !== true)
|
|
609
|
+
return 'detached';
|
|
610
|
+
const terminal = await this.probeTerminal();
|
|
611
|
+
if (terminal === 'live')
|
|
612
|
+
return 'attached';
|
|
613
|
+
if (terminal === 'silent')
|
|
614
|
+
return 'detached';
|
|
615
|
+
return 'unknown';
|
|
616
|
+
}
|
|
617
|
+
/** One answer to one prober; never claims the display, never throws. */
|
|
618
|
+
async answerQuery(socket) {
|
|
619
|
+
const verdict = await this.attachmentVerdict();
|
|
620
|
+
try {
|
|
621
|
+
socket.write(encodeQueryReply(verdict));
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// The prober gave up on its own deadline; nothing left to answer to.
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Ask the claimed relay to round-trip its terminal, with a deadline.
|
|
629
|
+
*
|
|
630
|
+
* Sharing one in-flight round trip matters: a second question while the first
|
|
631
|
+
* is unanswered would be settled by the first reply, and two windows asking at
|
|
632
|
+
* once is a normal resume, not an error.
|
|
633
|
+
*/
|
|
634
|
+
async probeTerminal() {
|
|
635
|
+
if (this.probeInFlight !== undefined)
|
|
636
|
+
return await this.probeInFlight;
|
|
637
|
+
const socket = this.socket;
|
|
638
|
+
if (socket === undefined)
|
|
639
|
+
return 'unknown';
|
|
640
|
+
const pending = new Promise(resolve => {
|
|
641
|
+
// Referenced on purpose: the timer *completes* the round trip, and an
|
|
642
|
+
// unref'd one would let the loop drain with the caller still waiting.
|
|
643
|
+
const timer = setTimeout(() => {
|
|
644
|
+
this.probeSettle = undefined;
|
|
645
|
+
resolve('unknown');
|
|
646
|
+
}, this.options.probeTimeoutMs ?? DISPLAY_PROBE_TIMEOUT_MS);
|
|
647
|
+
this.probeSettle = verdict => {
|
|
648
|
+
clearTimeout(timer);
|
|
649
|
+
this.probeSettle = undefined;
|
|
650
|
+
resolve(verdict);
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
this.probeInFlight = pending;
|
|
654
|
+
try {
|
|
655
|
+
socket.write(encodeProbe());
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
this.settleProbe('unknown');
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
return await pending;
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
this.probeInFlight = undefined;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
settleProbe(verdict) {
|
|
668
|
+
const settle = this.probeSettle;
|
|
669
|
+
this.probeSettle = undefined;
|
|
670
|
+
settle?.(verdict);
|
|
671
|
+
}
|
|
435
672
|
sendStdout(bytes) {
|
|
436
673
|
const socket = this.socket;
|
|
437
674
|
if (socket === undefined || this.attached !== true)
|
|
@@ -506,6 +743,65 @@ function isPidAlive(pid) {
|
|
|
506
743
|
export async function displaySockExists(path, timeoutMs = 250) {
|
|
507
744
|
return await probeDisplaySock(path, timeoutMs);
|
|
508
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* How long a prober waits for the Host's answer to {@link FRAME_QUERY}.
|
|
748
|
+
*
|
|
749
|
+
* It has to cover the relay's own two probe windows (see
|
|
750
|
+
* {@link DISPLAY_PROBE_TIMEOUT_MS}) plus the hop; the answer only takes this
|
|
751
|
+
* long when the display really is silent.
|
|
752
|
+
*/
|
|
753
|
+
export const DISPLAY_QUERY_TIMEOUT_MS = 4_000;
|
|
754
|
+
/**
|
|
755
|
+
* Ask a live Host whether a window is really on its session.
|
|
756
|
+
*
|
|
757
|
+
* `undefined` means the Host did not answer — an older Host that does not know
|
|
758
|
+
* the frame, or one whose event loop is stuck. Every caller must read that as
|
|
759
|
+
* "cannot tell" and never as "free": the cost of the doubt is one confirmation
|
|
760
|
+
* prompt, and the cost of guessing wrong is taking a session away from a window
|
|
761
|
+
* somebody is still typing in.
|
|
762
|
+
*/
|
|
763
|
+
export async function queryDisplayAttachment(path, timeoutMs = DISPLAY_QUERY_TIMEOUT_MS) {
|
|
764
|
+
return await new Promise(resolve => {
|
|
765
|
+
const socket = createConnection(path);
|
|
766
|
+
const reader = new FrameReader();
|
|
767
|
+
let settled = false;
|
|
768
|
+
const finish = (verdict) => {
|
|
769
|
+
if (settled)
|
|
770
|
+
return;
|
|
771
|
+
settled = true;
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
socket.destroy();
|
|
774
|
+
resolve(verdict);
|
|
775
|
+
};
|
|
776
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
777
|
+
socket.once('connect', () => {
|
|
778
|
+
try {
|
|
779
|
+
socket.write(encodeQuery());
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
finish(undefined);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
socket.on('data', chunk => {
|
|
786
|
+
let frames;
|
|
787
|
+
try {
|
|
788
|
+
frames = reader.push(chunk);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
finish(undefined);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
for (const frame of frames) {
|
|
795
|
+
if (frame.type === FRAME_QUERY_REPLY) {
|
|
796
|
+
finish(decodeQueryReply(frame.payload));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
socket.once('error', () => finish(undefined));
|
|
802
|
+
socket.once('close', () => finish(undefined));
|
|
803
|
+
});
|
|
804
|
+
}
|
|
509
805
|
function watchHostExit(child) {
|
|
510
806
|
let settle = () => { };
|
|
511
807
|
const exited = new Promise(resolve => { settle = resolve; });
|
|
@@ -521,6 +817,52 @@ function watchHostExit(child) {
|
|
|
521
817
|
},
|
|
522
818
|
};
|
|
523
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* Watch a Host this process did not spawn itself.
|
|
822
|
+
*
|
|
823
|
+
* The hidden-console bootstrap (see `hostBootstrapCommand`) starts the Host
|
|
824
|
+
* through PowerShell, so there is no `ChildProcess` handle to listen on — the
|
|
825
|
+
* only handle on the Host is the pid it printed. Polling that pid is enough for
|
|
826
|
+
* what the watch is for: a Host that dies before its display socket appears
|
|
827
|
+
* should be reported at once instead of after the whole boot timeout. The code
|
|
828
|
+
* is unknown here (null), and the poll interval is the detection delay; the
|
|
829
|
+
* watch is disposed as soon as the channel is up, so it never runs for the life
|
|
830
|
+
* of the session.
|
|
831
|
+
*
|
|
832
|
+
* The poll timer is deliberately **not** unref'd. `exited` is a promise this
|
|
833
|
+
* watch is the only thing that can resolve, and an unref'd timer does not keep
|
|
834
|
+
* the event loop alive: a caller that awaits nothing else — a test, or a
|
|
835
|
+
* launcher whose only remaining work is the boot — let the loop drain first and
|
|
836
|
+
* never saw the answer (node:test reports that as "Promise resolution is still
|
|
837
|
+
* pending but the event loop has already resolved", which is how this was
|
|
838
|
+
* found). Every caller disposes the watch once the channel is up, and
|
|
839
|
+
* `waitForDisplaySock` disposes it in its `finally`, so holding the loop for the
|
|
840
|
+
* boot window is the point rather than a leak.
|
|
841
|
+
*/
|
|
842
|
+
const HOST_PID_POLL_MS = 250;
|
|
843
|
+
/** Exported for the test that pins its loop-ref behaviour; not public API. */
|
|
844
|
+
export function watchHostPid(pid) {
|
|
845
|
+
let settle = () => { };
|
|
846
|
+
const exited = new Promise(resolve => { settle = resolve; });
|
|
847
|
+
let timer;
|
|
848
|
+
const tick = () => {
|
|
849
|
+
if (!isPidAlive(pid)) {
|
|
850
|
+
timer = undefined;
|
|
851
|
+
settle(null);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
timer = setTimeout(tick, HOST_PID_POLL_MS);
|
|
855
|
+
};
|
|
856
|
+
tick();
|
|
857
|
+
return {
|
|
858
|
+
exited,
|
|
859
|
+
dispose() {
|
|
860
|
+
if (timer !== undefined)
|
|
861
|
+
clearTimeout(timer);
|
|
862
|
+
timer = undefined;
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
}
|
|
524
866
|
/**
|
|
525
867
|
* How long a dead-pid report waits for the child's `exit` event before giving
|
|
526
868
|
* up on its code. The event is normally delivered within a tick; the bound only
|
|
@@ -717,32 +1059,75 @@ export function restoreTerminalInput(stdin = process.stdin) {
|
|
|
717
1059
|
}
|
|
718
1060
|
}
|
|
719
1061
|
/**
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
* POSIX wants `detached: true` (setsid) so the Host survives the launcher and a
|
|
724
|
-
* hung-up terminal.
|
|
1062
|
+
* Start the Host through the hidden-console bootstrap and return its pid, or
|
|
1063
|
+
* `undefined` when the bootstrap could not report one.
|
|
725
1064
|
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* Dropping `detached` there lets `windowsHide` do its job: the Host gets its
|
|
732
|
-
* own invisible console, and descendants inherit it instead of creating one.
|
|
733
|
-
* The Host still outlives the launcher: Windows does not kill children with
|
|
734
|
-
* their parent, and its console is its own, so closing the user's terminal does
|
|
735
|
-
* not reach it either.
|
|
1065
|
+
* `spawnSync` on purpose. The pid has to be in hand before this function
|
|
1066
|
+
* returns (the caller watches it, and the fallback must never leave two Hosts
|
|
1067
|
+
* for one session), and the cost is one bounded wait while the boot splash is
|
|
1068
|
+
* already on screen. PowerShell exits as soon as `Start-Process` has created the
|
|
1069
|
+
* Host, so the wait is its own start-up, not the Host's.
|
|
736
1070
|
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
1071
|
+
* Falling back is safe exactly when nothing was printed: `Start-Process -PassThru`
|
|
1072
|
+
* either starts the Host and prints its id, or throws before starting anything
|
|
1073
|
+
* (`$ErrorActionPreference = 'Stop'`). A *timeout* is the one case where a Host
|
|
1074
|
+
* might exist and the pid was lost, so it does not fall back — it reports.
|
|
739
1075
|
*/
|
|
740
|
-
export function
|
|
741
|
-
|
|
742
|
-
|
|
1076
|
+
export function spawnHostThroughBootstrap(bootstrap, options) {
|
|
1077
|
+
// A pid file left by an earlier boot would be read as this one's answer.
|
|
1078
|
+
try {
|
|
1079
|
+
rmSync(options.pidFile, { force: true });
|
|
1080
|
+
}
|
|
1081
|
+
catch { /* best effort */ }
|
|
1082
|
+
const result = spawnSync(bootstrap.command, bootstrap.args, {
|
|
1083
|
+
env: options.env,
|
|
1084
|
+
...hostSpawnOptions(options.platform),
|
|
1085
|
+
// No pipes at all. `Start-Process` may hand this script's stdio to the Host,
|
|
1086
|
+
// and a live reader of that pipe would wait for the Host to exit instead of
|
|
1087
|
+
// for the bootstrap: measured here as a 15 s stall before the pid was read.
|
|
1088
|
+
// The pid travels through a file, and the Host's own stderr is redirected by
|
|
1089
|
+
// the script itself.
|
|
1090
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1091
|
+
timeout: options.timeoutMs,
|
|
1092
|
+
});
|
|
1093
|
+
let written = '';
|
|
1094
|
+
try {
|
|
1095
|
+
written = readFileSync(options.pidFile, 'utf8').trim();
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
// No file: nothing was started (or the script failed before writing it).
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
rmSync(options.pidFile, { force: true });
|
|
1102
|
+
}
|
|
1103
|
+
catch { /* best effort */ }
|
|
1104
|
+
const match = /^(\d+)$/u.exec(written);
|
|
1105
|
+
if (match !== null) {
|
|
1106
|
+
const pid = Number(match[1]);
|
|
1107
|
+
// Checked before the timeout: a bootstrap that wrote the pid and then hung
|
|
1108
|
+
// still started exactly one Host, and that pid is the answer.
|
|
1109
|
+
if (Number.isInteger(pid) && pid > 0)
|
|
1110
|
+
return { pid };
|
|
1111
|
+
}
|
|
1112
|
+
const timedOut = result.error?.code === 'ETIMEDOUT';
|
|
1113
|
+
if (timedOut || result.signal !== null) {
|
|
1114
|
+
throw new Error(`dsh-ssh-tui: the hidden-console bootstrap did not report a host pid in ${options.timeoutMs}ms;`
|
|
1115
|
+
+ ' refusing to start a second host for this session');
|
|
1116
|
+
}
|
|
1117
|
+
return undefined;
|
|
743
1118
|
}
|
|
744
|
-
/**
|
|
745
|
-
|
|
1119
|
+
/**
|
|
1120
|
+
* Spawn a detached Host copy of this `dsh` invocation and return its sock path.
|
|
1121
|
+
*
|
|
1122
|
+
* On Windows the Host goes through {@link hostBootstrapCommand} when the OS
|
|
1123
|
+
* PowerShell is available: a direct spawn there cannot both survive the
|
|
1124
|
+
* launcher (libuv's `KILL_ON_JOB_CLOSE` job takes a non-detached child with it)
|
|
1125
|
+
* and avoid flashing console windows (`detached` is DETACHED_PROCESS, which makes
|
|
1126
|
+
* Windows ignore `CREATE_NO_WINDOW`). The bootstrap gives the Host a console of
|
|
1127
|
+
* its own, hidden — see `docs/platform.md`. Without it, the direct spawn below
|
|
1128
|
+
* is still what runs, with the old semantics.
|
|
1129
|
+
*/
|
|
1130
|
+
export function spawnDetachedHost(sessionId, platform = process.platform, options = {}) {
|
|
746
1131
|
const sock = sessionSockPath(sessionId);
|
|
747
1132
|
// On Windows the channel is a pipe name, which is not a file path: the log
|
|
748
1133
|
// must live in the state directory next to the locks instead.
|
|
@@ -750,17 +1135,63 @@ export function spawnDetachedHost(sessionId, platform = process.platform) {
|
|
|
750
1135
|
let errFd;
|
|
751
1136
|
try {
|
|
752
1137
|
mkdirSync(dirname(errFile), { recursive: true, mode: 0o700 });
|
|
1138
|
+
// The Host's stderr can quote a provider error; the directory and the log
|
|
1139
|
+
// both get the intent applied, since `mode` is POSIX-only.
|
|
1140
|
+
restrictPathToUserSync(dirname(errFile), { mode: 0o700, directory: true });
|
|
753
1141
|
errFd = openSync(errFile, 'w');
|
|
1142
|
+
restrictPathToUserSync(errFile, { mode: 0o600 });
|
|
754
1143
|
}
|
|
755
1144
|
catch {
|
|
756
1145
|
errFd = undefined;
|
|
757
1146
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1147
|
+
// DSH_HOME is pinned to the resolved absolute path: the Host chdirs into the
|
|
1148
|
+
// session's working directory before it listens, so an unset, blank or
|
|
1149
|
+
// relative home would otherwise resolve differently there and the two
|
|
1150
|
+
// processes would compute different channel names.
|
|
1151
|
+
const env = { ...process.env, [TUI_HOST_ENV]: '1', DSH_HOME: resolveDshHome() };
|
|
1152
|
+
const argv = hostArgvForSession(sessionId);
|
|
1153
|
+
// Without the stderr log there is nowhere to redirect the Host's stderr, and
|
|
1154
|
+
// leaving it un-redirected would hand it this process's stdio; the direct
|
|
1155
|
+
// spawn is the honest fallback there.
|
|
1156
|
+
const pidFile = sessionBootstrapPidPath(sessionId);
|
|
1157
|
+
const bootstrap = options.bootstrap === null
|
|
1158
|
+
? undefined
|
|
1159
|
+
: options.bootstrap ?? (errFd === undefined ? undefined : hostBootstrapCommand({
|
|
1160
|
+
platform,
|
|
1161
|
+
execPath: process.execPath,
|
|
1162
|
+
argv,
|
|
1163
|
+
stderrFile: errFile,
|
|
1164
|
+
pidFile,
|
|
1165
|
+
}));
|
|
1166
|
+
let started;
|
|
1167
|
+
if (bootstrap !== undefined) {
|
|
1168
|
+
started = spawnHostThroughBootstrap(bootstrap, {
|
|
1169
|
+
// The marker rides the bootstrap's environment, which `Start-Process`
|
|
1170
|
+
// passes on to the Host.
|
|
1171
|
+
env: bootstrapEnv(env),
|
|
1172
|
+
platform,
|
|
1173
|
+
timeoutMs: options.bootstrapTimeoutMs ?? HOST_BOOTSTRAP_TIMEOUT_MS,
|
|
1174
|
+
pidFile,
|
|
1175
|
+
});
|
|
1176
|
+
if (started !== undefined) {
|
|
1177
|
+
// The bootstrap redirects the Host's stderr to this path itself, so this
|
|
1178
|
+
// process must not keep a descriptor on it.
|
|
1179
|
+
if (errFd !== undefined) {
|
|
1180
|
+
try {
|
|
1181
|
+
closeSync(errFd);
|
|
1182
|
+
}
|
|
1183
|
+
catch { /* ignore */ }
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
pid: started.pid,
|
|
1187
|
+
sock,
|
|
1188
|
+
exitWatch: watchHostPid(started.pid),
|
|
1189
|
+
...errFd === undefined ? {} : { errFile },
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const child = spawn(process.execPath, argv, {
|
|
1194
|
+
env,
|
|
764
1195
|
...hostSpawnOptions(platform),
|
|
765
1196
|
stdio: ['ignore', 'ignore', errFd ?? 'ignore'],
|
|
766
1197
|
});
|
|
@@ -808,13 +1239,21 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
808
1239
|
const stdin = options.stdin ?? process.stdin;
|
|
809
1240
|
const stdout = options.stdout ?? process.stdout;
|
|
810
1241
|
const signals = options.signals ?? process;
|
|
811
|
-
|
|
812
|
-
|
|
1242
|
+
// The relay and the Host must agree on the screen: both ask the capability
|
|
1243
|
+
// table, so a console without an alternate screen never gets half of one.
|
|
1244
|
+
const useAltScreen = terminalCapabilities().alternateScreen;
|
|
813
1245
|
return await new Promise((resolve, reject) => {
|
|
814
1246
|
const socket = createConnection(path);
|
|
815
1247
|
const reader = new FrameReader();
|
|
816
1248
|
let settled = false;
|
|
817
1249
|
let live = false;
|
|
1250
|
+
/** Whether this terminal has ever answered a cursor probe. */
|
|
1251
|
+
let terminalAnswered = false;
|
|
1252
|
+
let probeInFlight = false;
|
|
1253
|
+
/** The value the Host already has, so only real changes are reported. */
|
|
1254
|
+
let reportedRtt;
|
|
1255
|
+
let recheckMisses = 0;
|
|
1256
|
+
let rttTimer;
|
|
818
1257
|
// Set when the Host tells us a newer Display took this session over. The
|
|
819
1258
|
// terminal restore below is the one write that must NOT happen then: this
|
|
820
1259
|
// relay no longer owns any screen, and if the link is dead (window killed,
|
|
@@ -845,6 +1284,9 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
845
1284
|
reject(error);
|
|
846
1285
|
};
|
|
847
1286
|
const cleanup = () => {
|
|
1287
|
+
if (rttTimer !== undefined)
|
|
1288
|
+
clearTimeout(rttTimer);
|
|
1289
|
+
rttTimer = undefined;
|
|
848
1290
|
pump.stop();
|
|
849
1291
|
stdout.removeListener('resize', onResize);
|
|
850
1292
|
stdin.removeListener('end', onLocalHangup);
|
|
@@ -863,7 +1305,7 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
863
1305
|
resizeTimer = undefined;
|
|
864
1306
|
}
|
|
865
1307
|
stdout.off('resize', onResize);
|
|
866
|
-
if (
|
|
1308
|
+
if (usesSigwinch()) {
|
|
867
1309
|
signals.off('SIGWINCH', onResize);
|
|
868
1310
|
}
|
|
869
1311
|
try {
|
|
@@ -881,7 +1323,10 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
881
1323
|
try {
|
|
882
1324
|
stdout.write('\x1b]0;\x07');
|
|
883
1325
|
stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
884
|
-
|
|
1326
|
+
// Leaving is always safe (an unused mode is ignored) and skipping it
|
|
1327
|
+
// is not: this relay may be the only one that gets to restore a screen
|
|
1328
|
+
// the Host entered under a different classification.
|
|
1329
|
+
stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[?1049l`);
|
|
885
1330
|
}
|
|
886
1331
|
catch {
|
|
887
1332
|
// TTY may already be gone
|
|
@@ -941,6 +1386,80 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
941
1386
|
sendResize();
|
|
942
1387
|
}, 20);
|
|
943
1388
|
};
|
|
1389
|
+
/**
|
|
1390
|
+
* Keep the Host's picture of the link current.
|
|
1391
|
+
*
|
|
1392
|
+
* The chip and the paint cadence (and the byte budget per frame) all come
|
|
1393
|
+
* from this number, and a link measured once at attach is a link measured at
|
|
1394
|
+
* its worst moment forever. The probe is a cursor round trip on a quiet
|
|
1395
|
+
* line: it costs one write and one reply, and the pump routes anything the
|
|
1396
|
+
* user typed meanwhile to the Host as usual.
|
|
1397
|
+
*/
|
|
1398
|
+
const recheckRtt = async () => {
|
|
1399
|
+
const measured = await pump.measure();
|
|
1400
|
+
if (settled)
|
|
1401
|
+
return;
|
|
1402
|
+
if (measured === undefined) {
|
|
1403
|
+
recheckMisses += 1;
|
|
1404
|
+
// A terminal that never answers DSR (a pipe, a dumb terminal) must not
|
|
1405
|
+
// be probed every twenty seconds for the rest of the session; one that
|
|
1406
|
+
// answers sometimes — a flapping link — keeps the normal cadence.
|
|
1407
|
+
scheduleRttRecheck(recheckMisses >= RTT_RECHECK_MAX_MISSES ? RTT_RECHECK_BACKOFF_MS : (options.rttRecheckMs ?? RTT_RECHECK_MS));
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
recheckMisses = 0;
|
|
1411
|
+
const moved = reportedRtt !== undefined && Math.abs(measured - reportedRtt) >= RTT_MOVED_MS;
|
|
1412
|
+
reportedRtt = measured;
|
|
1413
|
+
// Reported every time, even when it did not move: the Host takes the
|
|
1414
|
+
// median of the last few measurements, and a value sent once can never
|
|
1415
|
+
// outvote the stale one it replaced. An unchanged frame costs the screen
|
|
1416
|
+
// nothing (the painter is incremental), so the repetition is free.
|
|
1417
|
+
try {
|
|
1418
|
+
socket.write(encodeRtt(measured));
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
// The socket is gone; the close handler settles the relay.
|
|
1422
|
+
}
|
|
1423
|
+
scheduleRttRecheck(moved
|
|
1424
|
+
? RTT_RECHECK_CONFIRM_MS
|
|
1425
|
+
: (options.rttRecheckMs ?? RTT_RECHECK_MS));
|
|
1426
|
+
};
|
|
1427
|
+
const scheduleRttRecheck = (delayMs) => {
|
|
1428
|
+
if (settled)
|
|
1429
|
+
return;
|
|
1430
|
+
rttTimer = setTimeout(() => {
|
|
1431
|
+
rttTimer = undefined;
|
|
1432
|
+
void recheckRtt();
|
|
1433
|
+
}, delayMs);
|
|
1434
|
+
rttTimer.unref?.();
|
|
1435
|
+
};
|
|
1436
|
+
/**
|
|
1437
|
+
* The Host asking whether this display still has a terminal.
|
|
1438
|
+
*
|
|
1439
|
+
* The answer has to come from a real round trip. A cut SSH link leaves this
|
|
1440
|
+
* relay connected and its event loop healthy — the launcher sees no hangup
|
|
1441
|
+
* until sshd does, which can be hours — so anything cheaper (a ping its own
|
|
1442
|
+
* loop answers) would keep reporting a window nobody can see any more.
|
|
1443
|
+
*/
|
|
1444
|
+
const answerProbe = async () => {
|
|
1445
|
+
if (probeInFlight)
|
|
1446
|
+
return;
|
|
1447
|
+
probeInFlight = true;
|
|
1448
|
+
try {
|
|
1449
|
+
// A slow link gets the widened window before its silence is believed:
|
|
1450
|
+
// "silent" is what lets a resume take the session over, and a live
|
|
1451
|
+
// window must never be misread into it.
|
|
1452
|
+
const alive = await pump.measureOnce() || await pump.measureOnce(RTT_SLOW_SAMPLE_TIMEOUT_MS);
|
|
1453
|
+
terminalAnswered ||= alive;
|
|
1454
|
+
socket.write(encodeProbeReply(alive ? 'live' : terminalAnswered ? 'silent' : 'unknown'));
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
// The socket is gone; the close handler settles the relay.
|
|
1458
|
+
}
|
|
1459
|
+
finally {
|
|
1460
|
+
probeInFlight = false;
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
944
1463
|
const onLocalHangup = () => {
|
|
945
1464
|
finish('signal');
|
|
946
1465
|
};
|
|
@@ -977,6 +1496,11 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
977
1496
|
const rtt = await pump.measure();
|
|
978
1497
|
if (settled)
|
|
979
1498
|
return;
|
|
1499
|
+
// The baseline a liveness check is compared against: a terminal that
|
|
1500
|
+
// answered once and is silent later is gone, one that never answered
|
|
1501
|
+
// tells us nothing (see `TerminalVerdict`).
|
|
1502
|
+
terminalAnswered = rtt !== undefined;
|
|
1503
|
+
reportedRtt = rtt;
|
|
980
1504
|
const columns = stdout.columns || 80;
|
|
981
1505
|
const rows = stdout.rows || 24;
|
|
982
1506
|
socket.write(Buffer.concat([
|
|
@@ -985,6 +1509,7 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
985
1509
|
encodeRtt(rtt),
|
|
986
1510
|
]));
|
|
987
1511
|
live = true;
|
|
1512
|
+
scheduleRttRecheck(options.rttFirstRecheckMs ?? RTT_RECHECK_FIRST_MS);
|
|
988
1513
|
if (options.announce === true) {
|
|
989
1514
|
try {
|
|
990
1515
|
stdout.write('\r\x1b[2K');
|
|
@@ -1004,7 +1529,7 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1004
1529
|
}
|
|
1005
1530
|
pendingBytes = 0;
|
|
1006
1531
|
stdout.on('resize', onResize);
|
|
1007
|
-
if (
|
|
1532
|
+
if (usesSigwinch()) {
|
|
1008
1533
|
signals.on('SIGWINCH', onResize);
|
|
1009
1534
|
}
|
|
1010
1535
|
}
|
|
@@ -1044,6 +1569,9 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1044
1569
|
finish('replaced');
|
|
1045
1570
|
return;
|
|
1046
1571
|
}
|
|
1572
|
+
else if (frame.type === FRAME_PROBE) {
|
|
1573
|
+
void answerProbe();
|
|
1574
|
+
}
|
|
1047
1575
|
}
|
|
1048
1576
|
});
|
|
1049
1577
|
socket.on('close', () => finish('host-closed'));
|