Skip to content

Commit bff930e

Browse files
committed
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.
1 parent 7a8a591 commit bff930e

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

src/ink-win.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,42 @@
1-
const BSU = '\x1b[?2026h'
2-
const ESU = '\x1b[?2026l'
1+
// Begin/End Synchronized Update (DEC private mode 2026); exported so callers
2+
// can emit them; on Windows the filter below strips them from every write, so
3+
// even a concatenated BSU+payload write cannot reach ConPTY (#195).
4+
export const BSU = '\x1b[?2026h'
5+
export const ESU = '\x1b[?2026l'
36
let patched = false
47

8+
// split/join removes every occurrence and is hot-path cheap because the
9+
// includes() gate below runs first.
10+
export function stripSyncUpdateEscapes(chunk: string): string {
11+
return chunk.split(BSU).join('').split(ESU).join('')
12+
}
13+
514
export function patchStdoutForWindows(): void {
615
if (process.platform !== 'win32' || patched) return
716
patched = true
817

918
const origWrite = process.stdout.write.bind(process.stdout)
1019
process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean {
11-
if (chunk === BSU || chunk === ESU) return true
12-
return (origWrite as Function)(chunk, ...args)
20+
// Non-string chunks pass straight through unchanged; Buffers never carry
21+
// these escapes in this codebase, so scanning them is not worth the copy.
22+
if (typeof chunk !== 'string') {
23+
return (origWrite as Function)(chunk, ...args)
24+
}
25+
// Neither escape present: pass straight through.
26+
if (!chunk.includes(BSU) && !chunk.includes(ESU)) {
27+
return (origWrite as Function)(chunk, ...args)
28+
}
29+
const stripped = stripSyncUpdateEscapes(chunk)
30+
if (stripped.length > 0) {
31+
return (origWrite as Function)(stripped, ...args)
32+
}
33+
// The chunk was swallowed entirely. The old exact-match filter dropped the
34+
// callback too, which could wedge a callback-style writer; invoke it
35+
// asynchronously so a caller awaiting the callback never hangs.
36+
const last = args[args.length - 1]
37+
if (typeof last === 'function') {
38+
queueMicrotask(() => (last as () => void)())
39+
}
40+
return true
1341
} as typeof process.stdout.write
1442
}

tests/ink-win.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { BSU, ESU, stripSyncUpdateEscapes, patchStdoutForWindows } from '../src/ink-win.js'
3+
4+
describe('stripSyncUpdateEscapes', () => {
5+
it('strips an exact BSU chunk to empty', () => {
6+
expect(stripSyncUpdateEscapes(BSU)).toBe('')
7+
})
8+
9+
it('strips an exact ESU chunk to empty', () => {
10+
expect(stripSyncUpdateEscapes(ESU)).toBe('')
11+
})
12+
13+
it('strips a leading BSU from a concatenated clear write', () => {
14+
// #863 regression shape: the clear sequence glued to a BSU used to slip
15+
// through raw and hang Windows ConPTY.
16+
expect(stripSyncUpdateEscapes(BSU + '\x1b[2J\x1b[H')).toBe('\x1b[2J\x1b[H')
17+
})
18+
19+
it('strips a trailing ESU, and both ends at once', () => {
20+
expect(stripSyncUpdateEscapes('x' + ESU)).toBe('x')
21+
expect(stripSyncUpdateEscapes(BSU + 'x' + ESU)).toBe('x')
22+
})
23+
24+
it('removes every occurrence when escapes appear multiple times', () => {
25+
expect(stripSyncUpdateEscapes(BSU + 'a' + BSU + 'b' + ESU + 'c' + ESU)).toBe('abc')
26+
})
27+
28+
it('leaves a string without escapes untouched (same reference-equal content)', () => {
29+
const plain = 'status line \x1b[2J'
30+
expect(stripSyncUpdateEscapes(plain)).toBe(plain)
31+
})
32+
})
33+
34+
describe('patchStdoutForWindows', () => {
35+
it('is a no-op off win32: process.stdout.write stays reference-identical', () => {
36+
// Skip on actual Windows runners, where the patch legitimately applies.
37+
if (process.platform === 'win32') return
38+
const before = process.stdout.write
39+
patchStdoutForWindows()
40+
expect(process.stdout.write).toBe(before)
41+
})
42+
})

0 commit comments

Comments
 (0)