Skip to content

Commit c1d7fc3

Browse files
committed
fix(cli): surface terminal command broker protocol write failures through stderr
The parent treats a missing protocol file as 'protocol response was missing', but the broker's swallow-and-reap catch hid the underlying write error (ENOSPC, EACCES, ...). The broker now emits a bounded stderr marker before reaping itself, and the parent captures a 4KB stderr tail and retries the protocol read for 200ms (5x50ms) to tolerate tmpfs/AV races where close edges ahead of the file write. Classified as protocol_write_failed. Fixes #1359
1 parent 5962885 commit c1d7fc3

3 files changed

Lines changed: 148 additions & 24 deletions

File tree

cli/src/utils/__tests__/terminal-command-broker.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ describe('terminal command broker', () => {
105105
new Error('terminal command broker protocol response was missing'),
106106
),
107107
).toBe('protocol_missing')
108+
expect(
109+
classifyTerminalBrokerFailure(
110+
new Error(
111+
'terminal command broker protocol response was missing\nBroker stderr: [freebuff-broker] protocol write failed: ENOSPC',
112+
),
113+
),
114+
).toBe('protocol_write_failed')
108115
expect(sanitizeWindowsCliVersion('0.0.142')).toBe('0.0.142')
109116
expect(sanitizeWindowsCliVersion('private path/and details')).toBe(
110117
'unknown',
@@ -353,6 +360,65 @@ describe('terminal command broker', () => {
353360
])
354361
})
355362

363+
test('explains a missing protocol response with the broker stderr reason', async () => {
364+
if (process.platform === 'win32') return
365+
const failures: Array<{ stage: string; failureCode: string }> = []
366+
const broker = createTerminalCommandBroker({
367+
invocation: () => ({
368+
executable: '/bin/sh',
369+
args: [
370+
'-c',
371+
'echo "[freebuff-broker] protocol write failed: ENOSPC" >&2; exit 1',
372+
],
373+
}),
374+
reportFailure: (failure) => failures.push(failure),
375+
})
376+
377+
let failureMessage = ''
378+
try {
379+
await runTerminalCommand({
380+
command: `printf 'must not run'`,
381+
process_type: 'SYNC',
382+
cwd: process.cwd(),
383+
timeout_seconds: 10,
384+
terminalCommandBroker: broker,
385+
})
386+
} catch (error) {
387+
failureMessage = error instanceof Error ? error.message : String(error)
388+
}
389+
390+
expect(failureMessage).toContain('protocol response was missing')
391+
expect(failureMessage).toContain(
392+
'[freebuff-broker] protocol write failed: ENOSPC',
393+
)
394+
expect(failures).toEqual([
395+
{ stage: 'completion', failureCode: 'protocol_write_failed' },
396+
])
397+
})
398+
399+
test('tolerates a protocol file written just after the helper exits', async () => {
400+
if (process.platform === 'win32') return
401+
const broker = createTerminalCommandBroker({
402+
invocation: () => ({
403+
executable: '/bin/sh',
404+
args: [
405+
'-c',
406+
`(sleep 0.04; printf '%s\\n' '{"ok":true,"exitCode":0}' > "$CODEBUFF_TERMINAL_COMMAND_BROKER_PROTOCOL") & exit 0`,
407+
],
408+
}),
409+
})
410+
411+
const result = await runTerminalCommand({
412+
command: `printf 'must not run'`,
413+
process_type: 'SYNC',
414+
cwd: process.cwd(),
415+
timeout_seconds: 10,
416+
terminalCommandBroker: broker,
417+
})
418+
419+
expect(result[0].value).toMatchObject({ exitCode: 0 })
420+
})
421+
356422
test('does not add broker recovery guidance to a command spawn failure', async () => {
357423
const missingCwd = path.join(
358424
tmpdir(),

cli/src/utils/terminal-command-broker.ts

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ const MAX_REQUEST_BYTES = 4 * 1024 * 1024
2323
const MAX_PROTOCOL_BYTES = 64 * 1024
2424
const PROTOCOL_FILE_PREFIX = 'freebuff-terminal-command-broker-'
2525
const TERMINAL_COMMAND_BROKER_RECOVERY = 'Restart Freebuff and try again.'
26+
const PROTOCOL_READ_RETRY_MS = 50
27+
const PROTOCOL_READ_ATTEMPTS = 5
28+
const MAX_BROKER_STDERR_TAIL_BYTES = 4 * 1024
29+
// Cross-process contract: the detached broker emits this marker on stderr
30+
// before reaping itself so the parent can surface the write failure reason.
31+
const BROKER_STDERR_MARKER = '[freebuff-broker] protocol write failed:'
2632

2733
export type TerminalBrokerFailureStage = 'spawn' | 'stdio' | 'completion'
2834
export type TerminalBrokerFailureCode =
@@ -33,6 +39,7 @@ export type TerminalBrokerFailureCode =
3339
| 'epipe'
3440
| 'invalid_response'
3541
| 'protocol_missing'
42+
| 'protocol_write_failed'
3643
| 'response_too_large'
3744
| 'unknown'
3845

@@ -64,6 +71,7 @@ export function classifyTerminalBrokerFailure(
6471
const message = errorMessage(error).toLowerCase()
6572
if (message.includes('failed to connect')) return 'failed_to_connect'
6673
if (message.includes('invalid response')) return 'invalid_response'
74+
if (message.includes(BROKER_STDERR_MARKER)) return 'protocol_write_failed'
6775
if (message.includes('protocol response was missing')) {
6876
return 'protocol_missing'
6977
}
@@ -154,18 +162,41 @@ function removeProtocolFile(protocolPath: string): void {
154162
}
155163
}
156164

165+
function protocolWriteTargets(): string[] {
166+
try {
167+
return [protocolPathFromEnv()]
168+
} catch {
169+
// Path validation can only reject a broken environment; still attempt the
170+
// parent-provided path so a valid response is not lost to over-strictness.
171+
const raw = getSystemProcessEnv()[TERMINAL_COMMAND_BROKER_PROTOCOL_ENV]
172+
return raw ? [path.resolve(raw)] : []
173+
}
174+
}
175+
157176
function writeProtocol(message: BrokerProtocol): void {
158177
const payload = `${JSON.stringify(message)}\n`
159178
if (Buffer.byteLength(payload) > MAX_PROTOCOL_BYTES) {
160179
throw new Error('terminal command broker response was too large')
161180
}
162-
// A constrained one-shot file avoids Bun's unreliable custom stdio pipes on
163-
// Windows. `wx` ensures even an accidentally reused path is never replaced.
164-
writeFileSync(protocolPathFromEnv(), payload, {
165-
encoding: 'utf8',
166-
flag: 'wx',
167-
mode: 0o600,
168-
})
181+
let lastError: unknown = new Error(
182+
'terminal command broker protocol path was invalid',
183+
)
184+
for (const target of protocolWriteTargets()) {
185+
try {
186+
// A constrained one-shot file avoids Bun's unreliable custom stdio pipes on
187+
// Windows. `wx` ensures even an accidentally reused path is never replaced.
188+
writeFileSync(target, payload, {
189+
encoding: 'utf8',
190+
flag: 'wx',
191+
mode: 0o600,
192+
})
193+
return
194+
} catch (error) {
195+
lastError = error
196+
}
197+
}
198+
if (lastError instanceof Error) throw lastError
199+
throw new Error(errorMessage(lastError))
169200
}
170201

171202
function waitForParentDisconnect(): Promise<void> {
@@ -267,10 +298,14 @@ export async function serveTerminalCommandBroker(): Promise<void> {
267298

268299
try {
269300
writeProtocol(outcome.message)
270-
} catch {
271-
// Without a protocol response, the parent reports an actionable broker
272-
// failure. Keep the shell tree contained even when the temp write fails.
273-
await reapOwnProcessGroup()
301+
} catch (error) {
302+
// Never vanish silently: relay the write failure on stderr so the parent
303+
// can explain the missing protocol instead of a generic ENOENT message.
304+
try {
305+
process.stderr.write(`\n${BROKER_STDERR_MARKER} ${errorMessage(error)}\n`)
306+
} catch {
307+
// The pipe may already be gone; the parent still sees the missing file.
308+
}
274309
}
275310

276311
// Normal cleanup belongs to this detached process. In particular, Windows
@@ -417,28 +452,50 @@ export function createTerminalCommandBroker({
417452
child.stdin.on('error', () => {})
418453
child.stdin.end(JSON.stringify(request))
419454

455+
// The broker writes the one-shot file immediately before its own exit,
456+
// but close can still edge ahead on some filesystems (tmpfs, AV). ENOENT
457+
// is retried, and a bounded stderr tail explains an unrecoverable miss.
458+
let brokerStderrTail = ''
459+
child.stderr.on('data', (chunk: Buffer) => {
460+
const next = brokerStderrTail + chunk.toString('utf8')
461+
brokerStderrTail =
462+
next.length > MAX_BROKER_STDERR_TAIL_BYTES
463+
? next.slice(next.length - MAX_BROKER_STDERR_TAIL_BYTES)
464+
: next
465+
})
466+
420467
const closed = new Promise<void>((resolve, reject) => {
421468
child.once('error', reject)
422469
child.once('close', () => resolve())
423470
})
424-
const completion = closed
425-
.then(() => {
426-
let payload: Buffer
471+
472+
const readProtocol = async (): Promise<BrokerProtocol> => {
473+
for (let attempt = 0; attempt < PROTOCOL_READ_ATTEMPTS; attempt++) {
427474
try {
428-
payload = readFileSync(protocolPath)
475+
const payload = readFileSync(protocolPath)
476+
if (payload.byteLength > MAX_PROTOCOL_BYTES) {
477+
throw new Error('terminal command broker response was too large')
478+
}
479+
return parseProtocol(payload.toString('utf8').trim())
429480
} catch (error) {
430-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
431-
throw new Error(
432-
'terminal command broker protocol response was missing',
481+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
482+
if (attempt < PROTOCOL_READ_ATTEMPTS - 1) {
483+
await new Promise((resolve) =>
484+
setTimeout(resolve, PROTOCOL_READ_RETRY_MS),
433485
)
434486
}
435-
throw error
436-
}
437-
if (payload.byteLength > MAX_PROTOCOL_BYTES) {
438-
throw new Error('terminal command broker response was too large')
439487
}
440-
return parseProtocol(payload.toString('utf8').trim())
441-
})
488+
}
489+
const tail = brokerStderrTail.trim()
490+
throw new Error(
491+
tail
492+
? `terminal command broker protocol response was missing\nBroker stderr: ${tail}`
493+
: 'terminal command broker protocol response was missing',
494+
)
495+
}
496+
497+
const completion = closed
498+
.then(readProtocol)
442499
.catch((error) => {
443500
if (!terminationRequested) report('completion', error)
444501
throw brokerFailure(error)

cli/src/utils/windows-terminal-health.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type WindowsTerminalFailure = {
1818
| 'epipe'
1919
| 'invalid_response'
2020
| 'protocol_missing'
21+
| 'protocol_write_failed'
2122
| 'response_too_large'
2223
| 'exit_nonzero'
2324
| 'terminated'

0 commit comments

Comments
 (0)