@linxiraos/pi-tui 1.1.7 → 1.1.8

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/src/terminal.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  $env,
6
6
  isBunTestRuntime,
7
7
  isTerminalHeadless,
8
+ isWsl,
8
9
  logger,
9
10
  postmortem,
10
11
  restoreTerminalStderr,
@@ -142,62 +143,109 @@ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_
142
143
  }
143
144
 
144
145
  /**
145
- * Hard cap on bytes queued to a stalled stdout before its consumer is declared
146
- * gone. A live terminal drains within milliseconds, so a backlog this large —
147
- * far above any legitimate paint (a full session resume is a few MiB) — means
148
- * the PTY reader has stopped consuming entirely. Without the cap, `#safeWrite`
149
- * keeps handing cosmetic frames (the `hub wait` spinner, 500 ms progress
150
- * snapshots) to a writable buffer that never drains, growing RSS without bound
151
- * until the host runs out of memory. See #6854.
146
+ * Backlog ceiling that arms the stall watchdog. A live terminal keeps this
147
+ * near zero; crossing it means either a genuinely wedged PTY reader (#6854) or
148
+ * a single legitimately-huge frame — a `--resume` transcript repaint of many
149
+ * inline images is one multi-tens-of-MiB write (#10430). The two are told
150
+ * apart by {@link StdoutStallWatchdog} (drain progress), not by this number
151
+ * alone: inter-frame production is already gated at 256 KiB
152
+ * (`TUI.#deferRenderForOutputBacklog`), so the only way to reach this cap is a
153
+ * lone oversized frame or a reader that has stopped consuming entirely.
152
154
  */
153
155
  const MAX_STDOUT_BACKLOG_BYTES = 64 * 1024 * 1024;
154
156
 
155
157
  /**
156
- * Turns an unbounded, never-draining stdout writable buffer into a bounded
157
- * disconnect signal.
158
+ * Backlog at or below which stdout is healthy again: the pump has kept up, the
159
+ * TUI resumes composing frames, and a {@link StdoutStallWatchdog} episode ends.
160
+ * The TUI render gate (`TUI.#MAX_PENDING_OUTPUT_BYTES`) is this same value, so
161
+ * the watchdog stays armed across the entire range where frames are deferred —
162
+ * otherwise a consumer that wedges between this level and the arm cap is never
163
+ * re-sampled and the session freezes instead of disconnecting (#10434 review).
164
+ */
165
+ export const STDOUT_BACKLOG_CLEAR_BYTES = 256 * 1024;
166
+
167
+ /**
168
+ * How long an armed backlog may go without any drain progress before the
169
+ * consumer is declared gone. A slow-but-alive terminal keeps reaching new
170
+ * low-water marks (so it never trips); a wedged one that flushes nothing is
171
+ * torn down within this window.
172
+ */
173
+ const STDOUT_STALL_TIMEOUT_MS = 2_000;
174
+
175
+ /** Cadence at which {@link ProcessTerminal} re-samples the backlog while an episode is armed. */
176
+ const STDOUT_STALL_POLL_MS = 250;
177
+
178
+ /**
179
+ * Bounds a never-draining stdout backlog without killing a single large but
180
+ * actively-draining frame.
158
181
  *
159
182
  * `process.stdout.write()` returns `false` once its buffer exceeds the stream
160
- * high-water mark; the bytes stay queued and are only freed when the consumer
161
- * drains (the `drain` event). While the consumer keeps up, writes are accepted
162
- * and nothing accumulates. When it stalls, every subsequent write piles onto
163
- * the buffer — a stalled-but-alive PTY reader never throws, so the write path
164
- * has no other signal that output is going nowhere. This guard sums the bytes
165
- * queued since backpressure began and reports when that backlog crosses the
166
- * cap, at which point the caller treats the terminal as disconnected.
183
+ * high-water mark, and the off-thread pump's `pending()` climbs the same way;
184
+ * a stalled-but-alive PTY reader never throws, so the byte count is the only
185
+ * signal that output is going nowhere. Tripping on the instantaneous count
186
+ * alone is wrong: a legitimate oversized frame (a resume repaint of dozens of
187
+ * inline screenshots, #10430) briefly exceeds the cap and then drains.
188
+ *
189
+ * An episode starts when the backlog first exceeds `armBytes` and lasts until
190
+ * it drains back to `clearBytes` (healthy). The backlog can fall below
191
+ * `armBytes` while still unhealthy, so the episode must outlive that dip
192
+ * (#10434): during it the watchdog declares the terminal disconnected only when
193
+ * the backlog makes no drain progress (no new low-water mark) for `stallMs` —
194
+ * a draining terminal keeps lowering the mark and never trips, while a wedged
195
+ * one (#6854) still tears down within the window.
167
196
  *
168
197
  * Exported for unit testing; `ProcessTerminal` is the sole production user.
169
198
  */
170
- export class OutputBacklogGuard {
171
- #bytes = 0;
172
- #tracking = false;
173
-
174
- constructor(private readonly capBytes: number = MAX_STDOUT_BACKLOG_BYTES) {}
175
-
176
- /** True once a refused write started a backlog that has not yet drained. */
177
- get tracking(): boolean {
178
- return this.#tracking;
199
+ export class StdoutStallWatchdog {
200
+ #lowWater = Number.POSITIVE_INFINITY;
201
+ #stalledSinceMs = 0;
202
+ #armed = false;
203
+
204
+ constructor(
205
+ private readonly armBytes: number = MAX_STDOUT_BACKLOG_BYTES,
206
+ private readonly clearBytes: number = STDOUT_BACKLOG_CLEAR_BYTES,
207
+ private readonly stallMs: number = STDOUT_STALL_TIMEOUT_MS,
208
+ ) {}
209
+
210
+ /** True while an episode is active and the backlog must be polled to completion. */
211
+ get armed(): boolean {
212
+ return this.#armed;
179
213
  }
180
214
 
181
215
  /**
182
- * Record one `stdout.write()`: `accepted` is that call's return value and
183
- * `bytes` its encoded size. Returns true when the pending backlog now
184
- * exceeds the cap and the terminal should be treated as disconnected.
216
+ * Feed the current pending-byte count and clock reading. Returns true once an
217
+ * armed episode has gone `stallMs` with no drain progress, at which point the
218
+ * caller treats the terminal as disconnected.
185
219
  */
186
- record(accepted: boolean, bytes: number): boolean {
187
- if (!this.#tracking) {
188
- // Consumer is keeping up; nothing is queued.
189
- if (accepted) return false;
190
- // First refused write: backpressure has begun.
191
- this.#tracking = true;
220
+ sample(pending: number, nowMs: number): boolean {
221
+ if (!this.#armed) {
222
+ // Idle: only an oversized backlog starts an episode.
223
+ if (pending <= this.armBytes) return false;
224
+ this.#armed = true;
225
+ this.#lowWater = pending;
226
+ this.#stalledSinceMs = nowMs;
227
+ return false;
228
+ }
229
+ if (pending <= this.clearBytes) {
230
+ // Drained back to a healthy level: the episode is over.
231
+ this.reset();
232
+ return false;
192
233
  }
193
- this.#bytes += bytes;
194
- return this.#bytes > this.capBytes;
234
+ if (pending < this.#lowWater) {
235
+ // Drain progress: a new low-water mark restarts the stall clock.
236
+ this.#lowWater = pending;
237
+ this.#stalledSinceMs = nowMs;
238
+ return false;
239
+ }
240
+ // Still unhealthy with no new low-water mark since the clock started.
241
+ return nowMs - this.#stalledSinceMs >= this.stallMs;
195
242
  }
196
243
 
197
- /** Called on the stdout `drain` event: the buffer emptied, backlog cleared. */
244
+ /** Episode ended (drained) or terminal torn down: stop watching. */
198
245
  reset(): void {
199
- this.#bytes = 0;
200
- this.#tracking = false;
246
+ this.#armed = false;
247
+ this.#lowWater = Number.POSITIVE_INFINITY;
248
+ this.#stalledSinceMs = 0;
201
249
  }
202
250
  }
203
251
 
@@ -406,6 +454,13 @@ export interface TerminalStartOptions {
406
454
  }
407
455
  /** Identity of an accepted explicit terminal appearance refresh request. */
408
456
  export type TerminalAppearanceRequestToken = number;
457
+ /**
458
+ * Fired once per DEC private mode when DECRQM support resolves.
459
+ * `confirmed` is false when only the DA1 sentinel arrived.
460
+ * `status` is the DECRPM value (0 unrecognized, 1/2 set/reset, 3 permanently
461
+ * set, 4 permanently reset) when the terminal answered DECRQM.
462
+ */
463
+ export type PrivateModeReportHandler = (mode: number, supported: boolean, confirmed?: boolean, status?: number) => void;
409
464
  export interface Terminal {
410
465
  // Start the terminal with input, resize, and host-disconnect handlers.
411
466
  start(
@@ -529,8 +584,9 @@ export interface Terminal {
529
584
  * status resolves. `confirmed` is false when the terminal answered the DA1
530
585
  * sentinel without answering DECRQM, which proves only that querying support
531
586
  * is unavailable — not that the private mode itself is unsupported.
587
+ * `status` is the DECRPM value when the terminal answered DECRQM.
532
588
  */
533
- onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
589
+ onPrivateModeReport?(callback: PrivateModeReportHandler): void;
534
590
  }
535
591
 
536
592
  /**
@@ -542,9 +598,9 @@ export interface Terminal {
542
598
  * single predicate.
543
599
  */
544
600
  export function isConPTYHosted(): boolean {
545
- if (process.platform === "win32") return true;
546
- // WSL: stdout still crosses into ConPTY at the `wslhost` boundary.
547
- return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
601
+ // win32 always hosts through ConPTY; under WSL stdout still crosses into
602
+ // ConPTY at the `wslhost` boundary.
603
+ return process.platform === "win32" || isWsl(process.platform, $env);
548
604
  }
549
605
 
550
606
  /** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */
@@ -640,22 +696,19 @@ export class ProcessTerminal implements Terminal {
640
696
  #stdoutErrorHandler = (err: Error) => {
641
697
  this.#markTerminalDisconnected("stdout failed", err);
642
698
  };
643
- // Bounds the stdout writable buffer against a stalled PTY consumer: a
644
- // stalled-but-alive reader never throws, so #safeWrite has no error to catch
645
- // and the writable buffer grows without bound as cosmetic frames pile up.
646
- // See OutputBacklogGuard and #6854.
647
- #stdoutBacklog = new OutputBacklogGuard();
699
+ // Bounds the stdout backlog against a stalled PTY consumer without killing a
700
+ // single large-but-draining frame: a stalled-but-alive reader never throws,
701
+ // so the pending byte count is the only stall signal, and a legitimate
702
+ // oversized frame (a resume repaint of many inline images) must be allowed
703
+ // to drain. See StdoutStallWatchdog, #6854, and #10430.
704
+ #stdoutStall = new StdoutStallWatchdog();
705
+ #stdoutStallTimer?: Timer;
648
706
  // Off-thread output pump (unix TTYs): Bun's `process.stdout.write` blocks
649
707
  // the event loop until the terminal drains, so a slow/occluded emulator
650
708
  // froze the whole TUI for the duration of a multi-MB repaint. The pump
651
709
  // enqueues frames and performs the blocking write(2) on its own thread;
652
710
  // `pendingOutputBytes` exposes the backlog for render-side frame skipping.
653
711
  #outputPump?: TtyWriter;
654
- #stdoutDrainArmed = false;
655
- #stdoutDrainHandler = () => {
656
- this.#stdoutDrainArmed = false;
657
- this.#stdoutBacklog.reset();
658
- };
659
712
 
660
713
  #windowsVTInputRestore?: () => void;
661
714
  #xtermScrollToBottomRestoreModes = new Set<number>();
@@ -679,7 +732,7 @@ export class ProcessTerminal implements Terminal {
679
732
  #da1SentinelOwners: Da1SentinelOwner[] = [];
680
733
  /** Resolved DECRQM support per private mode (mode → supported). */
681
734
  #privateModeSupport = new Map<number, boolean>();
682
- #privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
735
+ #privateModeCallbacks: PrivateModeReportHandler[] = [];
683
736
  /** Whether DEC 2048 in-band resize notifications are currently enabled. */
684
737
  #inBandResizeActive = false;
685
738
  /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
@@ -770,7 +823,7 @@ export class ProcessTerminal implements Terminal {
770
823
  return token;
771
824
  }
772
825
 
773
- onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
826
+ onPrivateModeReport(callback: PrivateModeReportHandler): void {
774
827
  this.#privateModeCallbacks.push(callback);
775
828
  }
776
829
 
@@ -1306,10 +1359,13 @@ export class ProcessTerminal implements Terminal {
1306
1359
  }
1307
1360
  });
1308
1361
 
1309
- // Re-wrap paste content with bracketed paste markers for existing editor handling
1310
- this.#stdinBuffer.on("paste", (content: string) => {
1362
+ // Re-wrap paste content with bracketed paste markers for existing editor
1363
+ // handling. An Enter that shared the paste's stdin read rides along so
1364
+ // paste and submit reach the component focused right now, not one the
1365
+ // paste itself is about to open.
1366
+ this.#stdinBuffer.on("paste", (content: string, enter?: string) => {
1311
1367
  if (this.#inputHandler) {
1312
- this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
1368
+ this.#inputHandler(`\x1b[200~${content}\x1b[201~${enter ?? ""}`);
1313
1369
  }
1314
1370
  });
1315
1371
 
@@ -1430,6 +1486,7 @@ export class ProcessTerminal implements Terminal {
1430
1486
  const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
1431
1487
  const changed = mode !== this.#appearance;
1432
1488
  this.#appearance = mode;
1489
+ //DISABLED(biome-unknown-rule) lint/complexity/noUselessSpread: callbacks may unsubscribe while reporting
1433
1490
  for (const cb of [...this.#appearanceReportCallbacks]) {
1434
1491
  try {
1435
1492
  cb(mode, requestToken);
@@ -1491,7 +1548,7 @@ export class ProcessTerminal implements Terminal {
1491
1548
  }
1492
1549
 
1493
1550
  #handlePrivateModeReport(mode: number, status: string): void {
1494
- this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
1551
+ this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true, Number.parseInt(status, 10));
1495
1552
  if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
1496
1553
  this.#disableXtermScrollToBottomMode(mode);
1497
1554
  }
@@ -1503,12 +1560,12 @@ export class ProcessTerminal implements Terminal {
1503
1560
  * unsupported response from an absent response followed by the DA1 sentinel.
1504
1561
  * Enables DEC 2048 in-band resize only after positive confirmation.
1505
1562
  */
1506
- #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
1563
+ #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean, status?: number): void {
1507
1564
  if (this.#privateModeSupport.has(mode)) return;
1508
1565
  this.#privateModeSupport.set(mode, supported);
1509
1566
  for (const cb of this.#privateModeCallbacks) {
1510
1567
  try {
1511
- cb(mode, supported, confirmed);
1568
+ cb(mode, supported, confirmed, status);
1512
1569
  } catch {
1513
1570
  // Ignore subscriber errors — capability reporting must not crash input.
1514
1571
  }
@@ -1759,11 +1816,7 @@ export class ProcessTerminal implements Terminal {
1759
1816
  process.stdout.removeListener("resize", this.#stdoutResizeListener);
1760
1817
  this.#stdoutResizeListener = undefined;
1761
1818
  }
1762
- if (this.#stdoutDrainArmed) {
1763
- process.stdout.removeListener("drain", this.#stdoutDrainHandler);
1764
- this.#stdoutDrainArmed = false;
1765
- }
1766
- this.#stdoutBacklog.reset();
1819
+ this.#disarmStdoutStallWatchdog();
1767
1820
  this.#resizeHandler = undefined;
1768
1821
  // Flush the restore sequences enqueued above (bounded — a stalled PTY
1769
1822
  // must not wedge exit), then retire the pump. Later writes (emergency
@@ -1802,6 +1855,7 @@ export class ProcessTerminal implements Terminal {
1802
1855
  #markTerminalDisconnected(reason: string, err?: unknown): void {
1803
1856
  if (this.#dead) return;
1804
1857
  this.#dead = true;
1858
+ this.#disarmStdoutStallWatchdog();
1805
1859
  logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
1806
1860
 
1807
1861
  const disconnectHandler = this.#disconnectHandler;
@@ -1854,11 +1908,11 @@ export class ProcessTerminal implements Terminal {
1854
1908
  return;
1855
1909
  }
1856
1910
  try {
1857
- // Same stalled-consumer bound as the stream path (#6854): a PTY reader
1858
- // that never drains must tear the terminal down, not grow the queue.
1859
- if (pump.write(data) > MAX_STDOUT_BACKLOG_BYTES) {
1860
- this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
1861
- }
1911
+ // Feed the live backlog to the stall watchdog rather than tripping on
1912
+ // the instantaneous byte count: a single large-but-draining frame (a
1913
+ // resume repaint of many inline images) must open normally, while a
1914
+ // never-draining reader is still torn down (#6854, #10430).
1915
+ this.#trackStdoutBacklog(pump.write(data));
1862
1916
  } catch (err) {
1863
1917
  this.#markTerminalDisconnected("stdout failed", err);
1864
1918
  }
@@ -1882,26 +1936,19 @@ export class ProcessTerminal implements Terminal {
1882
1936
  // and a code-unit cap would let CJK transcript rows expand past the
1883
1937
  // threshold. See #2034 and #2095.
1884
1938
  const bytes = Buffer.byteLength(data, "utf8");
1885
- let accepted: boolean;
1886
1939
  if (this.#conpty && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
1887
- accepted = true;
1888
1940
  for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
1889
1941
  if (this.#dead) break;
1890
- accepted = process.stdout.write(chunk);
1942
+ process.stdout.write(chunk);
1891
1943
  }
1892
1944
  } else {
1893
- accepted = process.stdout.write(data);
1894
- }
1895
- // A stalled-but-alive PTY consumer never throws: write() just returns
1896
- // false and queues the bytes. Bound that never-draining backlog by
1897
- // declaring the terminal disconnected once it crosses the cap — the
1898
- // same clean-exit path a dead terminal takes (#6854).
1899
- if (this.#stdoutBacklog.record(accepted, bytes)) {
1900
- this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
1901
- } else if (this.#stdoutBacklog.tracking && !this.#stdoutDrainArmed) {
1902
- this.#stdoutDrainArmed = true;
1903
- process.stdout.once("drain", this.#stdoutDrainHandler);
1945
+ process.stdout.write(data);
1904
1946
  }
1947
+ // A stalled-but-alive PTY consumer never throws: write() just queues the
1948
+ // bytes and writableLength grows. Feed that backlog to the stall watchdog
1949
+ // so a genuinely wedged reader is bounded (#6854) without killing a lone
1950
+ // oversized frame that is still draining (#10430).
1951
+ this.#trackStdoutBacklog(process.stdout.writableLength ?? 0);
1905
1952
  } catch (err) {
1906
1953
  this.#markTerminalDisconnected("stdout failed", err);
1907
1954
  }
@@ -1917,6 +1964,47 @@ export class ProcessTerminal implements Terminal {
1917
1964
  return process.stdout.writableLength ?? 0;
1918
1965
  }
1919
1966
 
1967
+ /**
1968
+ * Reconcile the stdout backlog after a write or a poll. The watchdog runs an
1969
+ * episode from the moment the backlog crosses the arm cap until it drains to
1970
+ * a healthy level; while an episode is armed we keep a poll running because,
1971
+ * once the render gate (256 KiB) defers frames, no write is guaranteed to
1972
+ * re-sample the backlog — so a consumer that wedges anywhere above the
1973
+ * healthy threshold, even after the backlog dips below the arm cap, is still
1974
+ * caught. See {@link StdoutStallWatchdog}, #6854, #10430, and #10434.
1975
+ */
1976
+ #trackStdoutBacklog(pending: number): void {
1977
+ if (this.#stdoutStall.sample(pending, Date.now())) {
1978
+ this.#disarmStdoutStallWatchdog();
1979
+ this.#markTerminalDisconnected("stdout backlog stalled without draining; PTY consumer stalled");
1980
+ return;
1981
+ }
1982
+ if (!this.#stdoutStall.armed) {
1983
+ this.#disarmStdoutStallWatchdog();
1984
+ return;
1985
+ }
1986
+ if (!this.#stdoutStallTimer) {
1987
+ this.#stdoutStallTimer = setInterval(() => this.#pollStdoutStall(), STDOUT_STALL_POLL_MS);
1988
+ this.#stdoutStallTimer.unref?.();
1989
+ }
1990
+ }
1991
+
1992
+ #pollStdoutStall(): void {
1993
+ if (this.#dead) {
1994
+ this.#disarmStdoutStallWatchdog();
1995
+ return;
1996
+ }
1997
+ this.#trackStdoutBacklog(this.pendingOutputBytes);
1998
+ }
1999
+
2000
+ #disarmStdoutStallWatchdog(): void {
2001
+ this.#stdoutStall.reset();
2002
+ if (this.#stdoutStallTimer) {
2003
+ clearInterval(this.#stdoutStallTimer);
2004
+ this.#stdoutStallTimer = undefined;
2005
+ }
2006
+ }
2007
+
1920
2008
  get rows(): number {
1921
2009
  if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
1922
2010
  return process.stdout.rows || Number(Bun.env.LINES) || 24;