From bff930e645e2f10ed514eb228bdf670cdddbd2ff Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:06:47 +0300 Subject: [PATCH] fix(ink-win): strip synchronized-update escapes instead of exact-matching them The ConPTY guard swallowed a chunk only when it exactly equaled BSU or ESU, so any write concatenating them with other output reached Windows raw and hung ConPTY, which buffers the unimplemented 2026 sequence indefinitely (#195; the class recurred in #863's resize path). Strip every occurrence from string chunks instead: standalone escapes are swallowed, concatenated ones lose only the escapes, and a swallowed write now also honors its callback so callback-style writers cannot wedge. Non-string chunks pass through untouched. --- src/ink-win.ts | 36 ++++++++++++++++++++++++++++++++---- tests/ink-win.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/ink-win.test.ts diff --git a/src/ink-win.ts b/src/ink-win.ts index 5fd4bade..eb966f45 100644 --- a/src/ink-win.ts +++ b/src/ink-win.ts @@ -1,14 +1,42 @@ -const BSU = '\x1b[?2026h' -const ESU = '\x1b[?2026l' +// Begin/End Synchronized Update (DEC private mode 2026); exported so callers +// can emit them; on Windows the filter below strips them from every write, so +// even a concatenated BSU+payload write cannot reach ConPTY (#195). +export const BSU = '\x1b[?2026h' +export const ESU = '\x1b[?2026l' let patched = false +// split/join removes every occurrence and is hot-path cheap because the +// includes() gate below runs first. +export function stripSyncUpdateEscapes(chunk: string): string { + return chunk.split(BSU).join('').split(ESU).join('') +} + export function patchStdoutForWindows(): void { if (process.platform !== 'win32' || patched) return patched = true const origWrite = process.stdout.write.bind(process.stdout) process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean { - if (chunk === BSU || chunk === ESU) return true - return (origWrite as Function)(chunk, ...args) + // Non-string chunks pass straight through unchanged; Buffers never carry + // these escapes in this codebase, so scanning them is not worth the copy. + if (typeof chunk !== 'string') { + return (origWrite as Function)(chunk, ...args) + } + // Neither escape present: pass straight through. + if (!chunk.includes(BSU) && !chunk.includes(ESU)) { + return (origWrite as Function)(chunk, ...args) + } + const stripped = stripSyncUpdateEscapes(chunk) + if (stripped.length > 0) { + return (origWrite as Function)(stripped, ...args) + } + // The chunk was swallowed entirely. The old exact-match filter dropped the + // callback too, which could wedge a callback-style writer; invoke it + // asynchronously so a caller awaiting the callback never hangs. + const last = args[args.length - 1] + if (typeof last === 'function') { + queueMicrotask(() => (last as () => void)()) + } + return true } as typeof process.stdout.write } diff --git a/tests/ink-win.test.ts b/tests/ink-win.test.ts new file mode 100644 index 00000000..d58f7e2e --- /dev/null +++ b/tests/ink-win.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { BSU, ESU, stripSyncUpdateEscapes, patchStdoutForWindows } from '../src/ink-win.js' + +describe('stripSyncUpdateEscapes', () => { + it('strips an exact BSU chunk to empty', () => { + expect(stripSyncUpdateEscapes(BSU)).toBe('') + }) + + it('strips an exact ESU chunk to empty', () => { + expect(stripSyncUpdateEscapes(ESU)).toBe('') + }) + + it('strips a leading BSU from a concatenated clear write', () => { + // #863 regression shape: the clear sequence glued to a BSU used to slip + // through raw and hang Windows ConPTY. + expect(stripSyncUpdateEscapes(BSU + '\x1b[2J\x1b[H')).toBe('\x1b[2J\x1b[H') + }) + + it('strips a trailing ESU, and both ends at once', () => { + expect(stripSyncUpdateEscapes('x' + ESU)).toBe('x') + expect(stripSyncUpdateEscapes(BSU + 'x' + ESU)).toBe('x') + }) + + it('removes every occurrence when escapes appear multiple times', () => { + expect(stripSyncUpdateEscapes(BSU + 'a' + BSU + 'b' + ESU + 'c' + ESU)).toBe('abc') + }) + + it('leaves a string without escapes untouched (same reference-equal content)', () => { + const plain = 'status line \x1b[2J' + expect(stripSyncUpdateEscapes(plain)).toBe(plain) + }) +}) + +describe('patchStdoutForWindows', () => { + it('is a no-op off win32: process.stdout.write stays reference-identical', () => { + // Skip on actual Windows runners, where the patch legitimately applies. + if (process.platform === 'win32') return + const before = process.stdout.write + patchStdoutForWindows() + expect(process.stdout.write).toBe(before) + }) +})