98aa6da414
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.
96 lines
2.5 KiB
TypeScript
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()
|
|
})
|
|
}
|