Skip to content

Commit 7f9bb4e

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@f16ff43dd50b978b8ea0798aecd070718176cd02
1 parent 6d38870 commit 7f9bb4e

11 files changed

Lines changed: 372 additions & 51 deletions

File tree

bun.lock

Lines changed: 50 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/release-staging/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const SAFE_TERMINAL_RESET_SEQUENCES =
2626
'\x1b[?1006l' + // Disable SGR extended mouse mode
2727
'\x1b[?1004l' + // Disable focus reporting
2828
'\x1b[?2004l' + // Disable bracketed paste mode
29+
'\x1b[<u' + // Pop kitty keyboard protocol flags
30+
'\x1b[>4;0m' + // Reset modifyOtherKeys
2931
'\x1b[?25h' // Show cursor
3032

3133
const FULL_TERMINAL_RESET_SEQUENCES =

cli/release/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const SAFE_TERMINAL_RESET_SEQUENCES =
2626
'\x1b[?1006l' + // Disable SGR extended mouse mode
2727
'\x1b[?1004l' + // Disable focus reporting
2828
'\x1b[?2004l' + // Disable bracketed paste mode
29+
'\x1b[<u' + // Pop kitty keyboard protocol flags
30+
'\x1b[>4;0m' + // Reset modifyOtherKeys
2931
'\x1b[?25h' // Show cursor
3032

3133
const FULL_TERMINAL_RESET_SEQUENCES =
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Fixture process for terminal-watchdog.test.ts.
3+
*
4+
* Usage: bun terminal-watchdog-fixture.ts <mode> <ttyPath>
5+
* - mode "hang": start the watchdog and stay alive until killed by the test.
6+
* - mode "clean": start the watchdog, then stop it and exit (clean shutdown).
7+
*
8+
* Prints "ready" once the watchdog is armed so the test knows when to kill.
9+
*/
10+
import {
11+
startTerminalWatchdog,
12+
stopTerminalWatchdog,
13+
} from '../../utils/terminal-watchdog'
14+
15+
const [mode, ttyPath] = process.argv.slice(2)
16+
17+
if (!mode || !ttyPath) {
18+
console.error('usage: terminal-watchdog-fixture.ts <hang|clean> <ttyPath>')
19+
process.exit(2)
20+
}
21+
22+
startTerminalWatchdog({ ttyPath })
23+
24+
if (mode === 'clean') {
25+
stopTerminalWatchdog()
26+
console.log('ready')
27+
process.exit(0)
28+
}
29+
30+
console.log('ready')
31+
setInterval(() => {}, 1_000)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { readFileSync } from 'fs'
2+
import { join } from 'path'
3+
4+
import { describe, expect, test } from 'bun:test'
5+
6+
import { TERMINAL_RESET_SEQUENCES } from '../utils/terminal-reset-sequences'
7+
8+
/**
9+
* The npm wrapper release scripts are standalone published JS that can't
10+
* import the TS constant, so each carries its own copy of the reset
11+
* sequences (split into EXIT_ALTERNATE_SCREEN_SEQUENCE +
12+
* SAFE_TERMINAL_RESET_SEQUENCES). This guard turns silent drift into a test
13+
* failure: every sequence in the TS constant must appear as a source
14+
* literal in every wrapper.
15+
*/
16+
const REPO_ROOT = join(import.meta.dir, '..', '..', '..')
17+
18+
const WRAPPER_PATHS = [
19+
'cli/release/index.js',
20+
'cli/release-staging/index.js',
21+
'freebuff/cli/release/index.js',
22+
]
23+
24+
/** Split the runtime constant into per-sequence source literals ('\x1b…'). */
25+
function sequenceSourceLiterals(): string[] {
26+
return TERMINAL_RESET_SEQUENCES.split('\x1b')
27+
.filter(Boolean)
28+
.map((rest) => `'\\x1b${rest}'`)
29+
}
30+
31+
describe('terminal reset sequence copies stay in sync', () => {
32+
for (const wrapperPath of WRAPPER_PATHS) {
33+
test(wrapperPath, () => {
34+
const source = readFileSync(join(REPO_ROOT, wrapperPath), 'utf8')
35+
for (const literal of sequenceSourceLiterals()) {
36+
expect(source).toContain(literal)
37+
}
38+
})
39+
}
40+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { spawn } from 'child_process'
2+
import { mkdtempSync, readFileSync, rmSync } from 'fs'
3+
import { tmpdir } from 'os'
4+
import { join } from 'path'
5+
6+
import { afterAll, describe, expect, test } from 'bun:test'
7+
8+
import { TERMINAL_RESET_SEQUENCES } from '../utils/terminal-reset-sequences'
9+
10+
import type { ChildProcess } from 'child_process'
11+
12+
const FIXTURE = join(import.meta.dir, 'helpers', 'terminal-watchdog-fixture.ts')
13+
14+
const tempDir = mkdtempSync(join(tmpdir(), 'terminal-watchdog-'))
15+
16+
afterAll(() => {
17+
rmSync(tempDir, { recursive: true, force: true })
18+
})
19+
20+
function spawnFixture(mode: 'hang' | 'clean', ttyPath: string): ChildProcess {
21+
return spawn(process.execPath, [FIXTURE, mode, ttyPath], {
22+
stdio: ['ignore', 'pipe', 'inherit'],
23+
})
24+
}
25+
26+
/** Resolve once the fixture prints "ready" (watchdog armed). */
27+
function waitForReady(child: ChildProcess): Promise<void> {
28+
return new Promise((resolve, reject) => {
29+
let out = ''
30+
child.stdout!.on('data', (chunk: Buffer) => {
31+
out += chunk.toString()
32+
if (out.includes('ready')) resolve()
33+
})
34+
child.on('exit', () => resolve()) // "clean" mode exits immediately
35+
child.on('error', reject)
36+
})
37+
}
38+
39+
function waitForExit(child: ChildProcess): Promise<void> {
40+
return new Promise((resolve) => {
41+
if (child.exitCode !== null || child.signalCode !== null) return resolve()
42+
child.on('exit', () => resolve())
43+
})
44+
}
45+
46+
function readTty(ttyPath: string): string {
47+
try {
48+
return readFileSync(ttyPath, 'utf8')
49+
} catch {
50+
return ''
51+
}
52+
}
53+
54+
async function pollForContent(ttyPath: string, timeoutMs: number): Promise<string> {
55+
const deadline = Date.now() + timeoutMs
56+
while (Date.now() < deadline) {
57+
const content = readTty(ttyPath)
58+
if (content) return content
59+
await new Promise((r) => setTimeout(r, 50))
60+
}
61+
return readTty(ttyPath)
62+
}
63+
64+
// The watchdog is POSIX-only (sh + /dev/tty); on Windows the npm wrapper is
65+
// the safety net instead.
66+
describe.skipIf(process.platform === 'win32')('terminal watchdog', () => {
67+
test('writes reset sequences to the tty when the process dies uncleanly', async () => {
68+
const ttyPath = join(tempDir, 'unclean.out')
69+
const child = spawnFixture('hang', ttyPath)
70+
await waitForReady(child)
71+
72+
child.kill('SIGKILL')
73+
await waitForExit(child)
74+
75+
const written = await pollForContent(ttyPath, 5_000)
76+
expect(written).toBe(TERMINAL_RESET_SEQUENCES)
77+
}, 15_000)
78+
79+
test('stays silent when the process shuts down cleanly', async () => {
80+
const ttyPath = join(tempDir, 'clean.out')
81+
const child = spawnFixture('clean', ttyPath)
82+
await waitForExit(child)
83+
84+
// Give a killed-too-late watchdog time to (incorrectly) fire.
85+
await new Promise((r) => setTimeout(r, 500))
86+
expect(readTty(ttyPath)).toBe('')
87+
}, 15_000)
88+
})

cli/src/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ import { clearLogFile, logger } from './utils/logger'
4040
import { shouldShowProjectPicker } from './utils/project-picker'
4141
import { saveRecentProject } from './utils/recent-projects'
4242
import { startEngagementTracking } from './utils/engagement'
43-
import { installProcessCleanupHandlers, TERMINAL_RESET_SEQUENCES } from './utils/renderer-cleanup'
43+
import { installProcessCleanupHandlers } from './utils/renderer-cleanup'
44+
import { TERMINAL_RESET_SEQUENCES } from './utils/terminal-reset-sequences'
45+
import { startTerminalWatchdog, stopTerminalWatchdog } from './utils/terminal-watchdog'
4446
import { initializeSkillRegistry } from './utils/skill-registry'
4547
import { detectTerminalTheme } from './utils/terminal-color-detection'
4648
import { setOscDetectedTheme } from './utils/theme-system'
@@ -345,6 +347,7 @@ async function main(): Promise<void> {
345347
// If the renderer crashes during init, these ensure the error is visible
346348
// by exiting the alternate screen buffer before printing the error.
347349
const earlyFatalHandler = (error: unknown) => {
350+
stopTerminalWatchdog() // we reset the terminal ourselves below
348351
try {
349352
if (process.stdin.isTTY && process.stdin.setRawMode) {
350353
process.stdin.setRawMode(false)
@@ -369,6 +372,13 @@ async function main(): Promise<void> {
369372
process.on('uncaughtException', earlyFatalHandler)
370373
process.on('unhandledRejection', earlyFatalHandler)
371374

375+
// Last line of defense for uncatchable deaths (SIGKILL, native crashes,
376+
// kill sweeps that also take out the npm wrapper): a detached sh process
377+
// that resets the terminal when this process disappears. Started before the
378+
// renderer begins enabling terminal modes; the clean-shutdown path
379+
// (renderer-cleanup) disarms it.
380+
startTerminalWatchdog()
381+
372382
const renderer = await createCliRenderer({
373383
backgroundColor: 'transparent',
374384
exitOnCtrlC: false,

cli/src/utils/renderer-cleanup.ts

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { resetTerminalTitle } from './terminal-title'
22
import { flushLiveChatState } from './run-state-storage'
3+
import { TERMINAL_RESET_SEQUENCES } from './terminal-reset-sequences'
4+
import { stopTerminalWatchdog } from './terminal-watchdog'
35

46
import type { CliRenderer } from '@opentui/core'
57

@@ -8,30 +10,6 @@ let renderer: CliRenderer | null = null
810
let handlersInstalled = false
911
let terminalStateReset = false
1012

11-
/**
12-
* Terminal escape sequences to reset terminal state.
13-
* These are written directly to stdout to ensure they're sent even if the renderer is in a bad state.
14-
*
15-
* Sequences:
16-
* - \x1b[?1049l: Exit alternate screen buffer (restores main screen)
17-
* - \x1b[?1000l: Disable X10 mouse mode
18-
* - \x1b[?1002l: Disable button event mouse mode
19-
* - \x1b[?1003l: Disable any-event mouse mode (all motion tracking)
20-
* - \x1b[?1006l: Disable SGR extended mouse mode
21-
* - \x1b[?1004l: Disable focus reporting
22-
* - \x1b[?2004l: Disable bracketed paste mode
23-
* - \x1b[?25h: Show cursor (safety measure)
24-
*/
25-
export const TERMINAL_RESET_SEQUENCES =
26-
'\x1b[?1049l' + // Exit alternate screen buffer
27-
'\x1b[?1000l' + // Disable X10 mouse mode
28-
'\x1b[?1002l' + // Disable button event mouse mode
29-
'\x1b[?1003l' + // Disable any-event mouse mode (all motion)
30-
'\x1b[?1006l' + // Disable SGR extended mouse mode
31-
'\x1b[?1004l' + // Disable focus reporting
32-
'\x1b[?2004l' + // Disable bracketed paste mode
33-
'\x1b[?25h' // Show cursor
34-
3513
/**
3614
* Reset terminal state by writing escape sequences directly to stdout.
3715
* This is called BEFORE renderer.destroy() to ensure sequences are sent
@@ -69,6 +47,10 @@ function resetTerminalState(): void {
6947
* This resets terminal state to prevent garbled output after exit.
7048
*/
7149
function cleanup(): void {
50+
// We're on the clean-shutdown path, so the watchdog must not fire — kill it
51+
// before anything else (synchronous, so no race with our own exit).
52+
stopTerminalWatchdog()
53+
7254
// Persist any in-flight chat state first (synchronous, best-effort) so
7355
// closing the terminal or killing the process mid-run doesn't lose the turn.
7456
flushLiveChatState()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Terminal escape sequences to reset terminal state.
3+
* Shared by the in-process cleanup handlers (renderer-cleanup.ts) and the
4+
* sacrificial watchdog process (terminal-watchdog.ts).
5+
*
6+
* Keep this list in sync with the modes OpenTUI enables (see the enable
7+
* sequences in @opentui/core's native lib) and with
8+
* SAFE_TERMINAL_RESET_SEQUENCES in the npm wrapper release scripts
9+
* (cli/release/index.js, cli/release-staging/index.js,
10+
* freebuff/cli/release/index.js).
11+
*
12+
* Sequences:
13+
* - \x1b[?1049l: Exit alternate screen buffer (restores main screen)
14+
* - \x1b[?1000l: Disable X10 mouse mode
15+
* - \x1b[?1002l: Disable button event mouse mode
16+
* - \x1b[?1003l: Disable any-event mouse mode (all motion tracking)
17+
* - \x1b[?1006l: Disable SGR extended mouse mode
18+
* - \x1b[?1004l: Disable focus reporting
19+
* - \x1b[?2004l: Disable bracketed paste mode
20+
* - \x1b[<u: Pop the kitty keyboard protocol flags OpenTUI pushes
21+
* - \x1b[>4;0m: Reset xterm modifyOtherKeys (OpenTUI sets [>4;1m)
22+
* - \x1b[?25h: Show cursor (safety measure)
23+
*/
24+
export const TERMINAL_RESET_SEQUENCES =
25+
'\x1b[?1049l' + // Exit alternate screen buffer
26+
'\x1b[?1000l' + // Disable X10 mouse mode
27+
'\x1b[?1002l' + // Disable button event mouse mode
28+
'\x1b[?1003l' + // Disable any-event mouse mode (all motion)
29+
'\x1b[?1006l' + // Disable SGR extended mouse mode
30+
'\x1b[?1004l' + // Disable focus reporting
31+
'\x1b[?2004l' + // Disable bracketed paste mode
32+
'\x1b[<u' + // Pop kitty keyboard protocol flags
33+
'\x1b[>4;0m' + // Reset modifyOtherKeys
34+
'\x1b[?25h' // Show cursor

0 commit comments

Comments
 (0)