Files
hermes-agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
T
ethernet 98aa6da414 fix(tui): clipboard copy on linux/wayland + alt-screen stderr debug
Two interlocking bugs broke ctrl-c → clipboard on linux:

1. `probeLinuxCopy` and `copyNative` in `osc.ts` await
   `execFileNoThrow` for wl-copy / xclip / xsel. Those tools
   double-fork a daemon that holds the system selection live, and
   the daemon inherits stdio pipes from `spawn(stdio: 'pipe')`.
   Node's 'close' event only fires when stdio is fully closed → the
   daemon keeps the pipes open → 'close' never fires → the await
   leaks past the timeout (kill(SIGTERM) on an already-exited child
   is a no-op, daemon survives).

   Result: `linuxCopy` cache stays `undefined` permanently, the
   actual copy never runs, ctrl-c silently does nothing on
   wayland/x11. Reproduced in isolation, confirmed across wl-copy
   and a daemonization-shaped fixture.

   Fix: add `resolveOnExit` option to `execFileNoThrow`. When set,
   the promise settles on the immediate child's 'exit' event
   instead of waiting for stdio drainage. Wired into both the
   probe and the actual copy spawns for every clipboard tool
   (pbcopy, wl-copy, xclip, xsel, clip).

2. `logForDebugging` was a no-op stub. ink's `patchStderr()`
   redirects `process.stderr.write` (and therefore every
   `console.error`) into `logForDebugging` so stray writes can't
   corrupt the alt-screen diff. With the no-op, every diagnostic
   the TUI emitted in alt-screen mode landed in /dev/null —
   including `HERMES_TUI_DEBUG_CLIPBOARD` traces, which is what
   masked bug #1 in the first place.

   Fix: `logForDebugging` now appends to
   `~/.hermes/logs/tui-stderr.log` whenever any
   `HERMES_TUI_DEBUG*` flag is set. Override path with
   `HERMES_TUI_DEBUG_LOG=<path>`. Best-effort; unwritable paths
   silently drop the message rather than crashing the TUI.

Tests: 12 new vitest cases covering daemon-style child handling,
non-zero exit propagation, timeout behavior, double-resolve guard,
log-level routing, append semantics, and unwritable-path
resilience. The forever-hang case is committed as `it.skip` with
documentation so a reviewer can verify the bug by hand.
2026-05-11 19:00:17 -04:00

96 lines
2.5 KiB
TypeScript

import { spawn } from 'child_process'
type ExecFileOptions = {
input?: string
timeout?: number
useCwd?: boolean
env?: NodeJS.ProcessEnv
/** Resolve as soon as the child *exits*, instead of waiting for its
* stdio streams to close. Use this for tools that fork a daemon and
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
* child exits immediately, but `'close'` never fires because the
* daemon holds the pipes open. The caller must not depend on
* collecting stdout/stderr from that point on — only what arrived
* before exit is included in the resolved value. */
resolveOnExit?: boolean
}
export function execFileNoThrow(
file: string,
args: string[],
options: ExecFileOptions = {}
): Promise<{
stdout: string
stderr: string
code: number
error?: string
}> {
return new Promise(resolve => {
const child = spawn(file, args, {
cwd: options.useCwd ? process.cwd() : undefined,
env: options.env,
stdio: 'pipe'
})
let stdout = ''
let stderr = ''
let timedOut = false
let settled = false
const settle = (code: number, error?: string) => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code, ...(error ? { error } : {}) })
}
const timer = options.timeout
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
// When resolving on exit, SIGTERM-ing a child that has already
// exited is a no-op and `'exit'` won't fire again — settle here
// so the promise doesn't leak. Safe under settled-guard.
if (options.resolveOnExit) {
settle(124)
}
}, options.timeout)
: null
child.stdout?.on('data', chunk => {
stdout += String(chunk)
})
child.stderr?.on('data', chunk => {
stderr += String(chunk)
})
child.on('error', error => {
settle(1, String(error))
})
if (options.resolveOnExit) {
// 'exit' fires when the child process itself exits — even if the
// daemon it forked still holds the inherited stdio pipes open.
child.on('exit', code => {
settle(timedOut ? 124 : (code ?? 0))
})
} else {
child.on('close', code => {
settle(timedOut ? 124 : (code ?? 0))
})
}
if (options.input) {
child.stdin?.write(options.input)
}
child.stdin?.end()
})
}