@@ -23,6 +23,12 @@ const MAX_REQUEST_BYTES = 4 * 1024 * 1024
2323const MAX_PROTOCOL_BYTES = 64 * 1024
2424const PROTOCOL_FILE_PREFIX = 'freebuff-terminal-command-broker-'
2525const 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
2733export type TerminalBrokerFailureStage = 'spawn' | 'stdio' | 'completion'
2834export 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+
157176function 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
171202function 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 )
0 commit comments