dsh-ssh-tui 0.8.0 → 0.8.2-rc.1
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/CHANGELOG.md +150 -0
- package/README.en.md +74 -8
- package/README.md +451 -577
- package/docs/display-mode.md +122 -0
- package/docs/release-notes-0.7.4.md +91 -0
- package/docs/release-notes-0.8.0.md +56 -0
- package/docs/release-notes-0.8.1.md +79 -0
- package/docs/release-notes-0.8.2-rc.1.md +159 -0
- package/docs/remote-ops.md +51 -0
- package/docs/terminals.md +53 -0
- package/docs/windows.md +13 -0
- package/lib/approval-state.js +148 -0
- package/lib/approval-state.js.map +1 -0
- package/lib/attach.js +4 -4
- package/lib/attach.js.map +1 -1
- package/lib/auth-failure.js +74 -0
- package/lib/auth-failure.js.map +1 -1
- package/lib/color-depth.js +26 -0
- package/lib/color-depth.js.map +1 -1
- package/lib/commands.js +2 -0
- package/lib/commands.js.map +1 -1
- package/lib/copy-text.js +30 -8
- package/lib/copy-text.js.map +1 -1
- package/lib/dialogs.js +124 -0
- package/lib/dialogs.js.map +1 -1
- package/lib/display-mode.js +147 -0
- package/lib/display-mode.js.map +1 -0
- package/lib/display-sock.js +490 -35
- package/lib/display-sock.js.map +1 -1
- package/lib/footer-accents.js +28 -0
- package/lib/footer-accents.js.map +1 -0
- package/lib/footer-budget.js +328 -0
- package/lib/footer-budget.js.map +1 -0
- package/lib/footer-format.js +433 -0
- package/lib/footer-format.js.map +1 -0
- package/lib/footer-layout.js +407 -0
- package/lib/footer-layout.js.map +1 -0
- package/lib/footer-meters.js +185 -0
- package/lib/footer-meters.js.map +1 -0
- package/lib/footer.js +19 -561
- package/lib/footer.js.map +1 -1
- package/lib/glyph-measure.js +92 -0
- package/lib/glyph-measure.js.map +1 -0
- package/lib/i18n/en.js +83 -7
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +85 -7
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +73 -5
- package/lib/index.js.map +1 -1
- package/lib/paint.js +75 -17
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +14 -13
- package/lib/picker.js.map +1 -1
- package/lib/plan-projection.js +291 -0
- package/lib/plan-projection.js.map +1 -0
- package/lib/plan.js +61 -11
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +96 -0
- package/lib/platform.js.map +1 -1
- package/lib/question-state.js +83 -0
- package/lib/question-state.js.map +1 -0
- package/lib/representation.js +283 -0
- package/lib/representation.js.map +1 -0
- package/lib/screen.js +105 -0
- package/lib/screen.js.map +1 -0
- package/lib/selection.js +5 -3
- package/lib/selection.js.map +1 -1
- package/lib/session-blank.js +81 -0
- package/lib/session-blank.js.map +1 -0
- package/lib/session-list.js +102 -81
- package/lib/session-list.js.map +1 -1
- package/lib/startup.js +56 -0
- package/lib/startup.js.map +1 -1
- package/lib/stats.js +101 -3
- package/lib/stats.js.map +1 -1
- package/lib/subagent-model.js +8 -7
- package/lib/subagent-model.js.map +1 -1
- package/lib/term-text.js +436 -69
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-input.js +132 -10
- package/lib/terminal-input.js.map +1 -1
- package/lib/theme.js +335 -0
- package/lib/theme.js.map +1 -0
- package/lib/throughput.js +305 -0
- package/lib/throughput.js.map +1 -0
- package/lib/tool-present.js +67 -15
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +4186 -1133
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-state.d.ts +75 -0
- package/lib/types/attach.d.ts +6 -2
- package/lib/types/auth-failure.d.ts +55 -0
- package/lib/types/color-depth.d.ts +21 -0
- package/lib/types/commands.d.ts +6 -0
- package/lib/types/copy-text.d.ts +18 -1
- package/lib/types/dialogs.d.ts +147 -1
- package/lib/types/display-mode.d.ts +99 -0
- package/lib/types/display-sock.d.ts +90 -0
- package/lib/types/footer-accents.d.ts +22 -0
- package/lib/types/footer-budget.d.ts +204 -0
- package/lib/types/footer-format.d.ts +221 -0
- package/lib/types/footer-layout.d.ts +292 -0
- package/lib/types/footer-meters.d.ts +151 -0
- package/lib/types/footer.d.ts +19 -269
- package/lib/types/glyph-measure.d.ts +41 -0
- package/lib/types/index.d.ts +13 -0
- package/lib/types/paint.d.ts +48 -0
- package/lib/types/plan-projection.d.ts +162 -0
- package/lib/types/plan.d.ts +18 -2
- package/lib/types/platform.d.ts +83 -0
- package/lib/types/question-state.d.ts +96 -0
- package/lib/types/representation.d.ts +498 -0
- package/lib/types/screen.d.ts +126 -0
- package/lib/types/session-blank.d.ts +51 -0
- package/lib/types/session-list.d.ts +34 -0
- package/lib/types/startup.d.ts +30 -0
- package/lib/types/stats.d.ts +64 -1
- package/lib/types/subagent-model.d.ts +7 -6
- package/lib/types/term-text.d.ts +100 -24
- package/lib/types/terminal-input.d.ts +37 -0
- package/lib/types/theme.d.ts +109 -0
- package/lib/types/throughput.d.ts +163 -0
- package/lib/types/tool-present.d.ts +43 -2
- package/lib/types/transcript-types.d.ts +63 -2
- package/lib/types/tui.d.ts +960 -12
- package/package.json +73 -67
package/lib/display-sock.js
CHANGED
|
@@ -30,10 +30,13 @@ import { createConnection, createServer } from 'node:net';
|
|
|
30
30
|
import { mkdir, readFile, unlink } from 'node:fs/promises';
|
|
31
31
|
import { closeSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
32
32
|
import { homedir } from 'node:os';
|
|
33
|
-
import { RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
|
|
33
|
+
import { INPUT_HOLD_MS, RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
|
|
34
34
|
import { dirname, join, resolve } from 'node:path';
|
|
35
35
|
import { bootstrapEnv, hostBootstrapCommand, hostSpawnOptions, restrictPathToUserSync, usesSigwinch, } from './platform.js';
|
|
36
36
|
import { terminalCapabilities } from './terminal-caps.js';
|
|
37
|
+
import { createParentResizeFilter } from './display-mode.js';
|
|
38
|
+
import { ambiguousWidthIsTwo, setAmbiguousWidthMeasured, setAmbiguousWidthReserve } from './term-text.js';
|
|
39
|
+
import { measureAmbiguousGlyphWidth } from './glyph-measure.js';
|
|
37
40
|
export const FRAME_STDIN = 1;
|
|
38
41
|
export const FRAME_STDOUT = 2;
|
|
39
42
|
export const FRAME_RESIZE = 3;
|
|
@@ -49,14 +52,16 @@ export const FRAME_PROBE_REPLY = 9;
|
|
|
49
52
|
export const FRAME_QUERY = 10;
|
|
50
53
|
/** Host → prober: the answer to {@link FRAME_QUERY}. */
|
|
51
54
|
export const FRAME_QUERY_REPLY = 11;
|
|
52
|
-
const MAX_FRAME = 1024 * 1024;
|
|
53
55
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Relay → host: what this terminal does with the ambiguous glyphs.
|
|
57
|
+
*
|
|
58
|
+
* Payload: one byte, bit 0 = the terminal advances two cells, bit 1 = the second
|
|
59
|
+
* cell has to be reserved by us with a space. Sent right after HELLO, because the
|
|
60
|
+
* Host decides every width in the session and it is the relay — not the Host —
|
|
61
|
+
* that owns the terminal to measure on.
|
|
58
62
|
*/
|
|
59
|
-
const
|
|
63
|
+
export const FRAME_METRICS = 12;
|
|
64
|
+
const MAX_FRAME = 1024 * 1024;
|
|
60
65
|
/**
|
|
61
66
|
* How long a connection may stay silent before the Host treats it as a
|
|
62
67
|
* liveness probe and drops it.
|
|
@@ -76,6 +81,32 @@ const DISPLAY_HELLO_GRACE_MS = 6_000;
|
|
|
76
81
|
* for a slow link), so this only has to cover those plus the socket hop.
|
|
77
82
|
*/
|
|
78
83
|
const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
|
|
84
|
+
/**
|
|
85
|
+
* How long a farewell frame may hold its socket open before the backstop reaps
|
|
86
|
+
* it: `FRAME_REPLACED` to a kicked relay, `FRAME_GOODBYE` on the way out.
|
|
87
|
+
*
|
|
88
|
+
* The frame is what tells the other end *why* the socket is going — a bare close
|
|
89
|
+
* reads as a crash, and the launcher re-attaches over it. A write only queues on
|
|
90
|
+
* libuv until that end drains it, so the socket is reaped on the write callback
|
|
91
|
+
* and this is the bound that keeps a stalled reader from holding the shutdown
|
|
92
|
+
* (`launcher-exit.ts` bounds the whole graceful exit at 2 s anyway).
|
|
93
|
+
*/
|
|
94
|
+
const FAREWELL_FLUSH_MS = 250;
|
|
95
|
+
/**
|
|
96
|
+
* How long the glyph probe may hold the Host's output waiting for an answer.
|
|
97
|
+
*
|
|
98
|
+
* The timing measurement immediately before it already says what a round trip
|
|
99
|
+
* costs on this link, so the glyph probe does not need a fixed generous window:
|
|
100
|
+
* two round trips plus a margin covers a terminal that answers late, and a
|
|
101
|
+
* terminal that answers nothing costs the attach this budget once rather than
|
|
102
|
+
* the full {@link DISPLAY_PROBE_TIMEOUT_MS}.
|
|
103
|
+
* @param rttMs - the round trip just measured, if the terminal answered.
|
|
104
|
+
*/
|
|
105
|
+
function glyphProbeTimeoutMs(rttMs) {
|
|
106
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs <= 0)
|
|
107
|
+
return 200;
|
|
108
|
+
return Math.min(DISPLAY_PROBE_TIMEOUT_MS, Math.max(120, Math.ceil(rttMs * 2 + 80)));
|
|
109
|
+
}
|
|
79
110
|
/**
|
|
80
111
|
* The link is measured again this soon after an attach, then on the slower
|
|
81
112
|
* cadence below.
|
|
@@ -87,6 +118,28 @@ const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
|
|
|
87
118
|
* that case without waiting a full cadence.
|
|
88
119
|
*/
|
|
89
120
|
const RTT_RECHECK_FIRST_MS = 8_000;
|
|
121
|
+
/**
|
|
122
|
+
* How often a relay re-reads its own terminal's size.
|
|
123
|
+
*
|
|
124
|
+
* The resize *event* is the fast path and this is the backstop. A geometry the
|
|
125
|
+
* Host never learns about is not a cosmetic bug: the Host composes rows for the
|
|
126
|
+
* width it believes in, so a terminal that grew keeps the new columns filled with
|
|
127
|
+
* whatever was there before — the "half the screen is stale" report — and one
|
|
128
|
+
* that shrank gets its rows truncated at the edge. Resize events can be missed
|
|
129
|
+
* (a multiplexer that does not forward them, a window manager that moves a window
|
|
130
|
+
* between monitors, a console that only reports the *initial* size), and a
|
|
131
|
+
* one-second poll of two integers costs nothing next to being wrong all session.
|
|
132
|
+
*/
|
|
133
|
+
const SIZE_RECHECK_MS = 1_000;
|
|
134
|
+
/**
|
|
135
|
+
* How long a burst of resize events is collapsed before the geometry is sent.
|
|
136
|
+
*
|
|
137
|
+
* The first event of a burst bypasses it: a lone resize is reported at once, and
|
|
138
|
+
* only a *dragged* edge (an event every few milliseconds) pays the wait — that is
|
|
139
|
+
* where collapsing matters, because the Host would otherwise be told about every
|
|
140
|
+
* intermediate geometry the pointer passed through.
|
|
141
|
+
*/
|
|
142
|
+
const RELAY_RESIZE_DEBOUNCE_MS = 20;
|
|
90
143
|
/** How often an attached relay re-measures the link for the Host's chip. */
|
|
91
144
|
const RTT_RECHECK_MS = 20_000;
|
|
92
145
|
/** After this many silent measurements, stop asking so often. */
|
|
@@ -331,6 +384,30 @@ export function encodeFrame(type, payload = Buffer.alloc(0)) {
|
|
|
331
384
|
header.writeUInt8(type, 4);
|
|
332
385
|
return Buffer.concat([header, payload]);
|
|
333
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* The size a relay should report.
|
|
389
|
+
*
|
|
390
|
+
* A terminal knows its own size; a parent that speaks the protocol on pipes is
|
|
391
|
+
* not a terminal, so it declares one through `COLUMNS`/`LINES` — the convention
|
|
392
|
+
* every shell and TUI already shares — and corrects it later with a resize frame
|
|
393
|
+
* if the window changes. Without either, the historical 80×24 stands.
|
|
394
|
+
* @param stdout - the stream that may be a terminal.
|
|
395
|
+
* @param env - the environment to read.
|
|
396
|
+
* @returns the columns and rows to send as the first resize.
|
|
397
|
+
*/
|
|
398
|
+
export function relayTerminalSize(stdout = process.stdout, env = process.env) {
|
|
399
|
+
const fromEnv = (name, fallback) => {
|
|
400
|
+
const raw = Number.parseInt(String(env[name] ?? ''), 10);
|
|
401
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
402
|
+
};
|
|
403
|
+
const columns = Number.isFinite(stdout.columns) && (stdout.columns ?? 0) > 0
|
|
404
|
+
? Number(stdout.columns)
|
|
405
|
+
: fromEnv('COLUMNS', 80);
|
|
406
|
+
const rows = Number.isFinite(stdout.rows) && (stdout.rows ?? 0) > 0
|
|
407
|
+
? Number(stdout.rows)
|
|
408
|
+
: fromEnv('LINES', 24);
|
|
409
|
+
return { columns, rows };
|
|
410
|
+
}
|
|
334
411
|
export function encodeResize(columns, rows) {
|
|
335
412
|
const payload = Buffer.alloc(4);
|
|
336
413
|
payload.writeUInt16BE(Math.max(0, Math.min(0xffff, columns)), 0);
|
|
@@ -370,6 +447,17 @@ export function decodeProbeReply(payload) {
|
|
|
370
447
|
const value = decodeVerdict(payload);
|
|
371
448
|
return value === 1 ? 'live' : value === 2 ? 'silent' : 'unknown';
|
|
372
449
|
}
|
|
450
|
+
/** Encode {@link FRAME_METRICS}. */
|
|
451
|
+
export function encodeMetrics(metrics) {
|
|
452
|
+
return encodeFrame(FRAME_METRICS, Buffer.from([(metrics.wide ? 1 : 0) | (metrics.reserve ? 2 : 0)]));
|
|
453
|
+
}
|
|
454
|
+
/** Decode {@link FRAME_METRICS}; undefined when the payload is not one byte. */
|
|
455
|
+
export function decodeMetrics(payload) {
|
|
456
|
+
if (payload.length < 1)
|
|
457
|
+
return undefined;
|
|
458
|
+
const flags = payload[0] ?? 0;
|
|
459
|
+
return { wide: (flags & 1) !== 0, reserve: (flags & 2) !== 0 };
|
|
460
|
+
}
|
|
373
461
|
export function encodeQuery() {
|
|
374
462
|
return encodeFrame(FRAME_QUERY);
|
|
375
463
|
}
|
|
@@ -499,7 +587,7 @@ export class DisplayHost {
|
|
|
499
587
|
catch {
|
|
500
588
|
// already gone
|
|
501
589
|
}
|
|
502
|
-
},
|
|
590
|
+
}, FAREWELL_FLUSH_MS);
|
|
503
591
|
reap.unref?.();
|
|
504
592
|
try {
|
|
505
593
|
previous.end(encodeFrame(FRAME_REPLACED), () => {
|
|
@@ -574,6 +662,11 @@ export class DisplayHost {
|
|
|
574
662
|
if (size !== undefined)
|
|
575
663
|
this.handlers.onResize(size.columns, size.rows);
|
|
576
664
|
}
|
|
665
|
+
else if (frame.type === FRAME_METRICS) {
|
|
666
|
+
const metrics = decodeMetrics(frame.payload);
|
|
667
|
+
if (metrics !== undefined)
|
|
668
|
+
this.handlers.onMetrics?.(metrics);
|
|
669
|
+
}
|
|
577
670
|
else if (frame.type === FRAME_RTT) {
|
|
578
671
|
this.handlers.onRtt?.(decodeRtt(frame.payload));
|
|
579
672
|
}
|
|
@@ -669,6 +762,30 @@ export class DisplayHost {
|
|
|
669
762
|
this.probeSettle = undefined;
|
|
670
763
|
settle?.(verdict);
|
|
671
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* How many bytes are queued for the terminal and still undelivered.
|
|
767
|
+
*
|
|
768
|
+
* The Host composes every frame, and on a resize it is the only one that can
|
|
769
|
+
* decide whether there is room for another one: the socket the frames travel on
|
|
770
|
+
* belongs to this object. Without the number the Host can only *guess* how fast
|
|
771
|
+
* the link is (from the measured round trip) and pace itself conservatively,
|
|
772
|
+
* which is what made a drag lag behind the pointer on a fast link. With it, the
|
|
773
|
+
* rule becomes the honest one: paint the new size now, unless the previous frame
|
|
774
|
+
* has not left yet.
|
|
775
|
+
* @returns queued bytes, or undefined when no display is attached.
|
|
776
|
+
*/
|
|
777
|
+
pendingBytes() {
|
|
778
|
+
const socket = this.socket;
|
|
779
|
+
if (socket === undefined || this.attached !== true)
|
|
780
|
+
return undefined;
|
|
781
|
+
try {
|
|
782
|
+
const size = Number(socket.writableLength);
|
|
783
|
+
return Number.isFinite(size) ? size : undefined;
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
672
789
|
sendStdout(bytes) {
|
|
673
790
|
const socket = this.socket;
|
|
674
791
|
if (socket === undefined || this.attached !== true)
|
|
@@ -693,12 +810,49 @@ export class DisplayHost {
|
|
|
693
810
|
// ignore
|
|
694
811
|
}
|
|
695
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Say goodbye and let the frame leave before the socket is reaped.
|
|
815
|
+
*
|
|
816
|
+
* The frame is what tells the launcher to exit instead of reading the close as
|
|
817
|
+
* a crash and re-attaching (`attach.ts`) — the replacement path flushes
|
|
818
|
+
* `FRAME_REPLACED` for exactly the same reason. A write only *queues* on libuv
|
|
819
|
+
* until the peer drains it, and reaping the socket cancels what is still
|
|
820
|
+
* queued: on a busy link, with a starting session still painting into that
|
|
821
|
+
* socket, the goodbye was dropped along with those frames and the launcher
|
|
822
|
+
* re-attached over the user's own `/exit` instead of exiting.
|
|
823
|
+
*/
|
|
824
|
+
async farewellAndReap(socket) {
|
|
825
|
+
await new Promise(resolve => {
|
|
826
|
+
let settled = false;
|
|
827
|
+
const done = () => {
|
|
828
|
+
if (settled)
|
|
829
|
+
return;
|
|
830
|
+
settled = true;
|
|
831
|
+
clearTimeout(reap);
|
|
832
|
+
try {
|
|
833
|
+
socket.destroy();
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// already gone
|
|
837
|
+
}
|
|
838
|
+
resolve();
|
|
839
|
+
};
|
|
840
|
+
const reap = setTimeout(done, FAREWELL_FLUSH_MS);
|
|
841
|
+
reap.unref?.();
|
|
842
|
+
try {
|
|
843
|
+
socket.end(encodeFrame(FRAME_GOODBYE), done);
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
done();
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
696
850
|
async close() {
|
|
697
|
-
this.sendGoodbye();
|
|
698
851
|
const socket = this.socket;
|
|
699
852
|
this.socket = undefined;
|
|
700
853
|
this.attached = false;
|
|
701
|
-
socket
|
|
854
|
+
if (socket !== undefined)
|
|
855
|
+
await this.farewellAndReap(socket);
|
|
702
856
|
const server = this.server;
|
|
703
857
|
this.server = undefined;
|
|
704
858
|
await new Promise(resolve => {
|
|
@@ -1058,6 +1212,47 @@ export function restoreTerminalInput(stdin = process.stdin) {
|
|
|
1058
1212
|
// ignore
|
|
1059
1213
|
}
|
|
1060
1214
|
}
|
|
1215
|
+
/**
|
|
1216
|
+
* The environment a detached Host is started with.
|
|
1217
|
+
*
|
|
1218
|
+
* Notable entries: the marker that tells the child it *is* the Host, and the
|
|
1219
|
+
* ambiguous-width decision. The Host paints, but its own stdout is the relay
|
|
1220
|
+
* socket, so it cannot look at a TTY to know how wide the window's terminal
|
|
1221
|
+
* draws `①` or `—`; this process has that terminal, so it decides and hands the
|
|
1222
|
+
* answer over. An explicit value in the environment still wins.
|
|
1223
|
+
* @param env - the parent environment (a test passes its own).
|
|
1224
|
+
* @param onTerminal - whether *this* process owns a terminal; it is the evidence
|
|
1225
|
+
* the width table is chosen from, and a test harness is not a terminal.
|
|
1226
|
+
* @returns the child environment.
|
|
1227
|
+
*/
|
|
1228
|
+
export function hostChildEnv(env = process.env, onTerminal = process.stdout?.isTTY === true, measuredAmbiguousWide) {
|
|
1229
|
+
// Two facts travel, because they are two facts: how many cells the terminal
|
|
1230
|
+
// *advances* for these glyphs, and whether the second one has to be spent by
|
|
1231
|
+
// us with a reserving space (the glyph drawn wider than its cell). Sending only
|
|
1232
|
+
// the advance would leave the Host painting the collision the measurement was
|
|
1233
|
+
// taken to fix.
|
|
1234
|
+
const envValue = env.DSH_TUI_AMBIGUOUS_WIDTH;
|
|
1235
|
+
const measured = measuredAmbiguousWide === undefined
|
|
1236
|
+
? undefined
|
|
1237
|
+
: (measuredAmbiguousWide
|
|
1238
|
+
? { width: '2', reserve: '0' }
|
|
1239
|
+
: { width: '1', reserve: '1' });
|
|
1240
|
+
const fallback = measured === undefined
|
|
1241
|
+
? { width: ambiguousWidthIsTwo(env, onTerminal) ? '2' : '1', reserve: '0' }
|
|
1242
|
+
: measured;
|
|
1243
|
+
return {
|
|
1244
|
+
...env,
|
|
1245
|
+
[TUI_HOST_ENV]: '1',
|
|
1246
|
+
DSH_HOME: resolveDshHome(),
|
|
1247
|
+
// An explicit setting is the reader's, and is left alone in both directions.
|
|
1248
|
+
...(envValue === undefined
|
|
1249
|
+
? {
|
|
1250
|
+
DSH_TUI_AMBIGUOUS_WIDTH: fallback.width,
|
|
1251
|
+
DSH_TUI_AMBIGUOUS_RESERVE: fallback.reserve,
|
|
1252
|
+
}
|
|
1253
|
+
: {}),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1061
1256
|
/**
|
|
1062
1257
|
* Start the Host through the hidden-console bootstrap and return its pid, or
|
|
1063
1258
|
* `undefined` when the bootstrap could not report one.
|
|
@@ -1148,7 +1343,7 @@ export function spawnDetachedHost(sessionId, platform = process.platform, option
|
|
|
1148
1343
|
// session's working directory before it listens, so an unset, blank or
|
|
1149
1344
|
// relative home would otherwise resolve differently there and the two
|
|
1150
1345
|
// processes would compute different channel names.
|
|
1151
|
-
const env =
|
|
1346
|
+
const env = hostChildEnv(process.env, process.stdout?.isTTY === true, options.ambiguousWide);
|
|
1152
1347
|
const argv = hostArgvForSession(sessionId);
|
|
1153
1348
|
// Without the stderr log there is nowhere to redirect the Host's stderr, and
|
|
1154
1349
|
// leaving it un-redirected would hand it this process's stdio; the direct
|
|
@@ -1263,27 +1458,89 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1263
1458
|
let replaced = false;
|
|
1264
1459
|
const pending = [];
|
|
1265
1460
|
let pendingBytes = 0;
|
|
1461
|
+
// The Host's output while the glyph probe owns the terminal. Writing a frame
|
|
1462
|
+
// between the probe's print and its erase is what puts the erase on the
|
|
1463
|
+
// frame's row and leaves the glyphs on the screen the shell comes back to.
|
|
1464
|
+
let holdingOutput = false;
|
|
1465
|
+
let heldOutput = [];
|
|
1466
|
+
let heldOutputBytes = 0;
|
|
1467
|
+
const heldOutputLimit = 256 * 1024;
|
|
1468
|
+
const flushHeldOutput = () => {
|
|
1469
|
+
const held = heldOutput;
|
|
1470
|
+
heldOutput = [];
|
|
1471
|
+
heldOutputBytes = 0;
|
|
1472
|
+
for (const payload of held) {
|
|
1473
|
+
try {
|
|
1474
|
+
stdout.write(payload);
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
finish('signal');
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
/** Drop the frames held for the glyph probe without writing them. */
|
|
1483
|
+
const discardHeldOutput = () => {
|
|
1484
|
+
heldOutput = [];
|
|
1485
|
+
heldOutputBytes = 0;
|
|
1486
|
+
};
|
|
1266
1487
|
if (options.seed !== undefined && options.seed !== '') {
|
|
1267
1488
|
const seed = Buffer.from(options.seed, 'utf8');
|
|
1268
1489
|
pending.push(seed);
|
|
1269
1490
|
pendingBytes += seed.length;
|
|
1270
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Say out loud what settled the relay, under `DSH_TUI_DEBUG=1`.
|
|
1494
|
+
*
|
|
1495
|
+
* The reason decides whether the launcher exits or re-attaches
|
|
1496
|
+
* (`attach.ts`), and nothing on either side of the socket prints it: a red
|
|
1497
|
+
* probe shows frames and a process that never went, with no way to tell "the
|
|
1498
|
+
* Host said goodbye and the launcher ignored it" from "the link broke before
|
|
1499
|
+
* the goodbye arrived". One line each way is what makes that readable, and
|
|
1500
|
+
* it is off unless the variable is set.
|
|
1501
|
+
*/
|
|
1502
|
+
const note = (message) => {
|
|
1503
|
+
if (process.env.DSH_TUI_DEBUG === '1')
|
|
1504
|
+
process.stderr.write(`dsh-ssh-tui: display relay ${message}\n`);
|
|
1505
|
+
};
|
|
1271
1506
|
const finish = (reason) => {
|
|
1272
1507
|
if (settled)
|
|
1273
1508
|
return;
|
|
1274
1509
|
settled = true;
|
|
1275
|
-
|
|
1276
|
-
|
|
1510
|
+
note(`settling: ${reason}`);
|
|
1511
|
+
void releaseTerminal().then(() => {
|
|
1512
|
+
cleanup();
|
|
1513
|
+
resolve({ reason });
|
|
1514
|
+
});
|
|
1277
1515
|
};
|
|
1278
1516
|
/** Reject like `finish`, but restore the terminal first. */
|
|
1279
1517
|
const fail = (error) => {
|
|
1280
1518
|
if (settled)
|
|
1281
1519
|
return;
|
|
1282
1520
|
settled = true;
|
|
1283
|
-
|
|
1284
|
-
|
|
1521
|
+
note(`failed: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
|
|
1522
|
+
void releaseTerminal().then(() => {
|
|
1523
|
+
cleanup();
|
|
1524
|
+
reject(error);
|
|
1525
|
+
});
|
|
1526
|
+
};
|
|
1527
|
+
/**
|
|
1528
|
+
* One more round trip in raw mode before the shell gets the TTY back.
|
|
1529
|
+
*
|
|
1530
|
+
* A reply to a probe we have already given up on would otherwise be echoed
|
|
1531
|
+
* by the tty as `^[[25;1R` text at the user's prompt — the leaked escape
|
|
1532
|
+
* sequence readers report after leaving a session over a slow link.
|
|
1533
|
+
*/
|
|
1534
|
+
const releaseTerminal = async () => {
|
|
1535
|
+
const grace = reportedRtt === undefined
|
|
1536
|
+
? 0
|
|
1537
|
+
: Math.min(400, Math.max(60, Math.ceil(reportedRtt * 2 + 40)));
|
|
1538
|
+
await pump.handBack(grace);
|
|
1285
1539
|
};
|
|
1286
1540
|
const cleanup = () => {
|
|
1541
|
+
if (sizeTimer !== undefined)
|
|
1542
|
+
clearTimeout(sizeTimer);
|
|
1543
|
+
sizeTimer = undefined;
|
|
1287
1544
|
if (rttTimer !== undefined)
|
|
1288
1545
|
clearTimeout(rttTimer);
|
|
1289
1546
|
rttTimer = undefined;
|
|
@@ -1295,7 +1552,7 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1295
1552
|
signals.removeListener('SIGHUP', onLocalHangup);
|
|
1296
1553
|
signals.removeListener('SIGTERM', onLocalHangup);
|
|
1297
1554
|
try {
|
|
1298
|
-
stdin.setRawMode(false);
|
|
1555
|
+
stdin.setRawMode?.(false);
|
|
1299
1556
|
}
|
|
1300
1557
|
catch {
|
|
1301
1558
|
// ignore
|
|
@@ -1304,6 +1561,14 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1304
1561
|
clearTimeout(resizeTimer);
|
|
1305
1562
|
resizeTimer = undefined;
|
|
1306
1563
|
}
|
|
1564
|
+
// A run still held for a report that never came is dropped, not forwarded:
|
|
1565
|
+
// the relay is going away, and every path here has already stopped reading
|
|
1566
|
+
// stdin (`handBack` above marks the pump as dropping), so nothing can turn
|
|
1567
|
+
// it into a report now.
|
|
1568
|
+
if (parentResizeTimer !== undefined) {
|
|
1569
|
+
clearTimeout(parentResizeTimer);
|
|
1570
|
+
parentResizeTimer = undefined;
|
|
1571
|
+
}
|
|
1307
1572
|
stdout.off('resize', onResize);
|
|
1308
1573
|
if (usesSigwinch()) {
|
|
1309
1574
|
signals.off('SIGWINCH', onResize);
|
|
@@ -1339,7 +1604,9 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1339
1604
|
* on the floor: the probe owned the only stdin listener and the forwarder
|
|
1340
1605
|
* was attached afterwards. Hold them (bounded) and flush on HELLO.
|
|
1341
1606
|
*/
|
|
1342
|
-
const
|
|
1607
|
+
const parentResize = createParentResizeFilter();
|
|
1608
|
+
let parentResizeTimer;
|
|
1609
|
+
const forwardInput = (text) => {
|
|
1343
1610
|
if (text === '')
|
|
1344
1611
|
return;
|
|
1345
1612
|
const bytes = Buffer.from(text, 'utf8');
|
|
@@ -1358,34 +1625,139 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1358
1625
|
finish('host-closed');
|
|
1359
1626
|
}
|
|
1360
1627
|
};
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1628
|
+
/**
|
|
1629
|
+
* Hand a held run over once its window passes.
|
|
1630
|
+
*
|
|
1631
|
+
* A lone `ESC` is the case this exists for: it is the first byte of a report
|
|
1632
|
+
* that could still be split across two reads, but it is far more often the
|
|
1633
|
+
* user's Escape key — and the pump in front of this filter has already given
|
|
1634
|
+
* it the same window. Held without a deadline of its own, Escape reached the
|
|
1635
|
+
* Host only when another key arrived, and then glued to it as an Alt chord:
|
|
1636
|
+
* cancelling a dialog or interrupting a running turn did nothing.
|
|
1637
|
+
*/
|
|
1638
|
+
const releaseParentResize = () => {
|
|
1639
|
+
if (parentResizeTimer !== undefined) {
|
|
1640
|
+
clearTimeout(parentResizeTimer);
|
|
1641
|
+
parentResizeTimer = undefined;
|
|
1642
|
+
}
|
|
1643
|
+
const held = parentResize.flush();
|
|
1644
|
+
// A relay that has been replaced (or told goodbye) writes nothing more —
|
|
1645
|
+
// not even a key it was still holding. `finish` only settles the relay and
|
|
1646
|
+
// then waits out a round trip before `cleanup`, so this deadline can fire
|
|
1647
|
+
// inside that window; the key goes with the link it was typed on.
|
|
1648
|
+
if (settled)
|
|
1649
|
+
return;
|
|
1650
|
+
forwardInput(held);
|
|
1651
|
+
};
|
|
1372
1652
|
let resizeTimer;
|
|
1653
|
+
/**
|
|
1654
|
+
* The geometry to report, resolved at the moment it is sent.
|
|
1655
|
+
*
|
|
1656
|
+
* Two callers own the size in two different ways, and conflating them is what
|
|
1657
|
+
* made a window resize do nothing at all:
|
|
1658
|
+
*
|
|
1659
|
+
* - a **pipe parent** has no `resize` event and no SIGWINCH; it declares the
|
|
1660
|
+
* panel's geometry with `CSI 8 ; rows ; cols t`, which arrives on the input
|
|
1661
|
+
* pipe and is remembered here (`reported`), and
|
|
1662
|
+
* - a **terminal we own** is read *now*. Snapshotting `relayTerminalSize` once
|
|
1663
|
+
* per relay and re-sending it from the debounced handler meant every resize
|
|
1664
|
+
* after the first reported the attach-time geometry, so the Host saw no
|
|
1665
|
+
* change, skipped the repaint, and the frame stayed at the old width while
|
|
1666
|
+
* the user dragged the window.
|
|
1667
|
+
*
|
|
1668
|
+
* Reading at send time also collapses a burst of events to the newest size,
|
|
1669
|
+
* which is the point of the debounce in the first place.
|
|
1670
|
+
*/
|
|
1671
|
+
let reportedSize;
|
|
1672
|
+
/** What the Host was told last, so a re-check only speaks when it moved. */
|
|
1673
|
+
let lastSentSize = { columns: 0, rows: 0 };
|
|
1674
|
+
let sizeTimer;
|
|
1675
|
+
const currentSize = () => reportedSize ?? relayTerminalSize(stdout);
|
|
1373
1676
|
const sendResize = () => {
|
|
1677
|
+
const size = currentSize();
|
|
1678
|
+
lastSentSize = { columns: size.columns, rows: size.rows };
|
|
1374
1679
|
try {
|
|
1375
|
-
socket.write(encodeResize(
|
|
1680
|
+
socket.write(encodeResize(size.columns, size.rows));
|
|
1376
1681
|
}
|
|
1377
1682
|
catch {
|
|
1378
1683
|
finish('host-closed');
|
|
1379
1684
|
}
|
|
1380
1685
|
};
|
|
1686
|
+
/**
|
|
1687
|
+
* Report the size again if it moved without an event saying so.
|
|
1688
|
+
*
|
|
1689
|
+
* Only the terminal this relay owns is polled: a pipe parent's geometry is
|
|
1690
|
+
* the parent's to declare, and reading `columns` there would answer with a
|
|
1691
|
+
* default the parent never chose.
|
|
1692
|
+
*/
|
|
1693
|
+
const recheckSize = () => {
|
|
1694
|
+
if (settled)
|
|
1695
|
+
return;
|
|
1696
|
+
if (reportedSize === undefined) {
|
|
1697
|
+
const now = relayTerminalSize(stdout);
|
|
1698
|
+
if (now.columns !== lastSentSize.columns || now.rows !== lastSentSize.rows)
|
|
1699
|
+
sendResize();
|
|
1700
|
+
}
|
|
1701
|
+
sizeTimer = setTimeout(recheckSize, options.sizeRecheckMs ?? SIZE_RECHECK_MS);
|
|
1702
|
+
sizeTimer.unref?.();
|
|
1703
|
+
};
|
|
1704
|
+
/**
|
|
1705
|
+
* Report a resize, collapsing a burst.
|
|
1706
|
+
*
|
|
1707
|
+
* The first event of a burst goes out at once: a single resize (a window
|
|
1708
|
+
* snap, a `tmux` pane change) has nothing to be collapsed with, and making it
|
|
1709
|
+
* wait 20 ms added that delay to every one of them for no benefit. The
|
|
1710
|
+
* debounce is for the rest of the burst — a dragged edge emits events every
|
|
1711
|
+
* few milliseconds, and the Host only needs the geometry the drag lands on.
|
|
1712
|
+
*/
|
|
1381
1713
|
const onResize = () => {
|
|
1382
|
-
if (resizeTimer
|
|
1383
|
-
|
|
1714
|
+
if (resizeTimer === undefined) {
|
|
1715
|
+
sendResize();
|
|
1716
|
+
resizeTimer = setTimeout(() => {
|
|
1717
|
+
resizeTimer = undefined;
|
|
1718
|
+
}, RELAY_RESIZE_DEBOUNCE_MS);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
clearTimeout(resizeTimer);
|
|
1384
1722
|
resizeTimer = setTimeout(() => {
|
|
1385
1723
|
resizeTimer = undefined;
|
|
1386
1724
|
sendResize();
|
|
1387
|
-
},
|
|
1725
|
+
}, RELAY_RESIZE_DEBOUNCE_MS);
|
|
1726
|
+
};
|
|
1727
|
+
const deliver = (text) => {
|
|
1728
|
+
// A pipe parent has no `resize` event to fire and no SIGWINCH to raise:
|
|
1729
|
+
// `CSI 8 ; rows ; cols t` on the input pipe is how it says the panel is
|
|
1730
|
+
// now a different size. Those bytes are not typing, and the rest of the
|
|
1731
|
+
// read is.
|
|
1732
|
+
const { forward, sizes } = parentResize.push(text);
|
|
1733
|
+
for (const reported of sizes) {
|
|
1734
|
+
// The parent's declaration outranks the terminal we cannot see: a pipe
|
|
1735
|
+
// parent may not even have a `columns` for this process to read.
|
|
1736
|
+
reportedSize = { columns: reported.columns, rows: reported.rows };
|
|
1737
|
+
sendResize();
|
|
1738
|
+
}
|
|
1739
|
+
if (parentResize.pending) {
|
|
1740
|
+
if (parentResizeTimer !== undefined)
|
|
1741
|
+
clearTimeout(parentResizeTimer);
|
|
1742
|
+
// Referenced on purpose, like the pump's own hold timer: this timer
|
|
1743
|
+
// *completes* an operation the caller started (releasing a held key),
|
|
1744
|
+
// and an unref'd one would let the event loop drain with the release
|
|
1745
|
+
// still pending.
|
|
1746
|
+
parentResizeTimer = setTimeout(releaseParentResize, INPUT_HOLD_MS);
|
|
1747
|
+
}
|
|
1748
|
+
forwardInput(forward);
|
|
1388
1749
|
};
|
|
1750
|
+
const pump = new TerminalInputPump({
|
|
1751
|
+
stdin,
|
|
1752
|
+
stdout,
|
|
1753
|
+
onInput: deliver,
|
|
1754
|
+
...(options.ssh === undefined ? {} : { ssh: options.ssh }),
|
|
1755
|
+
// Only while the boot splash is up: the TUI is not painted yet, so a
|
|
1756
|
+
// diagnostic line cannot garble a live screen.
|
|
1757
|
+
...(process.env.DSH_TUI_DEBUG === '1'
|
|
1758
|
+
? { debug: (message) => { process.stderr.write(`dsh-ssh-tui: ${message}\n`); } }
|
|
1759
|
+
: {}),
|
|
1760
|
+
});
|
|
1389
1761
|
/**
|
|
1390
1762
|
* Keep the Host's picture of the link current.
|
|
1391
1763
|
*
|
|
@@ -1396,6 +1768,10 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1396
1768
|
* user typed meanwhile to the Host as usual.
|
|
1397
1769
|
*/
|
|
1398
1770
|
const recheckRtt = async () => {
|
|
1771
|
+
// Never probe a terminal we have already given back: the request would be
|
|
1772
|
+
// answered by the shell's tty in cooked mode, which echoes it as text.
|
|
1773
|
+
if (settled)
|
|
1774
|
+
return;
|
|
1399
1775
|
const measured = await pump.measure();
|
|
1400
1776
|
if (settled)
|
|
1401
1777
|
return;
|
|
@@ -1469,7 +1845,10 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1469
1845
|
socket.on('connect', () => {
|
|
1470
1846
|
void (async () => {
|
|
1471
1847
|
try {
|
|
1472
|
-
|
|
1848
|
+
// Optional: a pipe parent (`DSH_TUI_DISPLAY=stdio`) has no line
|
|
1849
|
+
// discipline to put in raw mode, and calling it unguarded threw here —
|
|
1850
|
+
// before the relay could say hello, which made the mode unusable.
|
|
1851
|
+
stdin.setRawMode?.(true);
|
|
1473
1852
|
stdin.resume();
|
|
1474
1853
|
// Subscribed before the first await: an EOF that lands during the
|
|
1475
1854
|
// probe (SSH dropped while the TTY was quiet) used to be missed
|
|
@@ -1501,15 +1880,78 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1501
1880
|
// tells us nothing (see `TerminalVerdict`).
|
|
1502
1881
|
terminalAnswered = rtt !== undefined;
|
|
1503
1882
|
reportedRtt = rtt;
|
|
1504
|
-
|
|
1505
|
-
|
|
1883
|
+
// The handshake carries the geometry as it is *now*: a parent that
|
|
1884
|
+
// declared a size on the input pipe before HELLO has already won
|
|
1885
|
+
// (`reportedSize`), and a terminal is read fresh.
|
|
1886
|
+
const { columns, rows } = currentSize();
|
|
1887
|
+
// The handshake *is* a resize report, and the size poll compares
|
|
1888
|
+
// against what the Host was told: leave this out and the first poll
|
|
1889
|
+
// speaks up to repeat a geometry the Host already has.
|
|
1890
|
+
lastSentSize = { columns, rows };
|
|
1506
1891
|
socket.write(Buffer.concat([
|
|
1507
1892
|
encodeFrame(FRAME_HELLO),
|
|
1508
1893
|
encodeResize(columns, rows),
|
|
1509
1894
|
encodeRtt(rtt),
|
|
1510
1895
|
]));
|
|
1511
1896
|
live = true;
|
|
1897
|
+
// Measure what this terminal does with `①` and friends, now that the
|
|
1898
|
+
// pump is running and the reply will be swallowed rather than echoed.
|
|
1899
|
+
// `DSH_TUI_NO_GLYPH_PROBE=1` keeps the write out of the stream entirely
|
|
1900
|
+
// for a harness that asserts the exact bytes of an attach.
|
|
1901
|
+
// The Host decides every width in the session, so the verdict travels
|
|
1902
|
+
// as a frame; it is sent after HELLO, because the Host ignores
|
|
1903
|
+
// everything that arrives before the claim.
|
|
1904
|
+
//
|
|
1905
|
+
// The probe prints the glyphs and then asks where the cursor ended up:
|
|
1906
|
+
// both writes belong to the terminal, and nothing else may land between
|
|
1907
|
+
// them. On a slow SSH link the answer takes longer than the Host takes
|
|
1908
|
+
// to paint its first frame, so the frame used to arrive inside that
|
|
1909
|
+
// window — the erase then wiped a row of it, and the glyphs stayed on
|
|
1910
|
+
// the screen for the shell to show again on the way out. Hold the
|
|
1911
|
+
// Host's output for the round trip instead; it is one frame, and the
|
|
1912
|
+
// boot splash is what the user is looking at.
|
|
1913
|
+
// A terminal that never answered the timing probe will not answer this
|
|
1914
|
+
// one either: asking would only cost the reply budget at every attach.
|
|
1915
|
+
if (terminalAnswered && process.env.DSH_TUI_NO_GLYPH_PROBE !== '1') {
|
|
1916
|
+
holdingOutput = true;
|
|
1917
|
+
let measured;
|
|
1918
|
+
try {
|
|
1919
|
+
measured = await measureAmbiguousGlyphWidth({
|
|
1920
|
+
pump,
|
|
1921
|
+
timeoutMs: glyphProbeTimeoutMs(rtt ?? reportedRtt),
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
finally {
|
|
1925
|
+
holdingOutput = false;
|
|
1926
|
+
// A relay that was replaced (or told goodbye) while the probe was
|
|
1927
|
+
// waiting owes this link nothing — that is the replacement path's
|
|
1928
|
+
// whole contract. Flushing here put the old Host's queued frame
|
|
1929
|
+
// back on a window the user has left: a live one shows a stale
|
|
1930
|
+
// frame, and a dropped SSH link buffers it until it comes back,
|
|
1931
|
+
// where it paints over the session that took over.
|
|
1932
|
+
if (settled)
|
|
1933
|
+
discardHeldOutput();
|
|
1934
|
+
else
|
|
1935
|
+
flushHeldOutput();
|
|
1936
|
+
}
|
|
1937
|
+
if (settled)
|
|
1938
|
+
return;
|
|
1939
|
+
if (measured !== undefined) {
|
|
1940
|
+
const reserve = measured.wide === false;
|
|
1941
|
+
setAmbiguousWidthReserve(reserve);
|
|
1942
|
+
setAmbiguousWidthMeasured(measured.wide);
|
|
1943
|
+
try {
|
|
1944
|
+
socket.write(encodeMetrics({ wide: measured.wide, reserve }));
|
|
1945
|
+
}
|
|
1946
|
+
catch {
|
|
1947
|
+
// The link is gone; the Host keeps the locale default.
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1512
1951
|
scheduleRttRecheck(options.rttFirstRecheckMs ?? RTT_RECHECK_FIRST_MS);
|
|
1952
|
+
// The size backstop, started with the geometry the handshake claimed:
|
|
1953
|
+
// every later poll compares against what the Host was actually told.
|
|
1954
|
+
recheckSize();
|
|
1513
1955
|
if (options.announce === true) {
|
|
1514
1956
|
try {
|
|
1515
1957
|
stdout.write('\r\x1b[2K');
|
|
@@ -1549,6 +1991,19 @@ export async function runDisplayRelay(path, options = {}) {
|
|
|
1549
1991
|
}
|
|
1550
1992
|
for (const frame of frames) {
|
|
1551
1993
|
if (frame.type === FRAME_STDOUT) {
|
|
1994
|
+
if (holdingOutput) {
|
|
1995
|
+
// Bound the hold: a Host that streams into a probe that never comes
|
|
1996
|
+
// back must not be able to grow this without bound.
|
|
1997
|
+
if (heldOutputBytes + frame.payload.length > heldOutputLimit) {
|
|
1998
|
+
holdingOutput = false;
|
|
1999
|
+
flushHeldOutput();
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
heldOutput.push(frame.payload);
|
|
2003
|
+
heldOutputBytes += frame.payload.length;
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1552
2007
|
try {
|
|
1553
2008
|
stdout.write(frame.payload);
|
|
1554
2009
|
}
|