Files
hermes-agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
T
ethernet c42edd8055 fix(tui): clipboard copy on linux/wayland
`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).

Tests: 5 new vitest cases covering daemon-style child handling,
non-zero exit propagation, timeout behavior, and double-resolve
guard. The forever-hang case is committed as `it.skip` with
documentation so a reviewer can verify the bug by hand.
2026-05-20 19:47:30 -04:00

147 lines
5.6 KiB
TypeScript

import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { execFileNoThrow } from './execFileNoThrow.js'
// These tests shell out to /bin/sh, use chmodSync(0o755), and rely on
// POSIX sleep/job control. They will not work on Windows.
const onWindows = process.platform === 'win32'
// We simulate `wl-copy`'s daemonization behavior with a tiny shell script:
// 1. Fork a short-lived background sleeper that inherits stdio (so the
// parent process's pipes can never close).
// 2. Record the sleeper PID to a file so afterEach can clean it up.
// 3. Exit immediately with status 0.
//
// Without resolveOnExit, the await on `'close'` hangs until SIGTERM at
// timeout — exactly the production wl-copy bug. With resolveOnExit, the
// promise settles on `'exit'` regardless of the inherited pipes.
let scriptDir: string
let daemonScript: string
let sleeperPids: number[]
/** Read the PID file the daemon script writes, and track it for afterEach cleanup. */
function trackSleeperPid(pidFile: string): void {
try {
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10)
if (pid > 0) {
sleeperPids.push(pid)
}
} catch {
// PID file not written or unreadable — sleeper may have already exited.
}
}
beforeEach(() => {
sleeperPids = []
scriptDir = join(tmpdir(), `hermes-execfile-test-${process.pid}-${Date.now()}`)
mkdirSync(scriptDir, { recursive: true })
daemonScript = join(scriptDir, 'fake-daemonizer.sh')
// Posix sh: the `sleep 3 &` child inherits stdin/stdout/stderr from the
// shell, which inherited them from `spawn(stdio: 'pipe')`. The shell
// exits but its child (the sleeper) keeps the pipes open. Mirrors how
// wl-copy double-forks then exits while the daemon holds the selection.
// The sleeper writes its PID to $1 so we can clean it up reliably.
writeFileSync(daemonScript, '#!/bin/sh\nsleep 3 &\necho $! > "$1"\nexit 0\n')
chmodSync(daemonScript, 0o755)
})
afterEach(() => {
// Kill orphaned sleepers so they don't accumulate across watch runs.
for (const pid of sleeperPids) {
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already exited — fine.
}
}
rmSync(scriptDir, { recursive: true, force: true })
})
describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
// Skipped because the bug it documents is a forever-hang. Without
// resolveOnExit, the 'close' event doesn't fire when the immediate
// child has exited but a forked daemon still holds stdio open. Even
// SIGTERM at the timeout doesn't help — the daemon survives it. To
// verify by hand: remove `it.skip` and watch the test timeout. This
// test is here so a reviewer reading the resolveOnExit option knows
// *why* every clipboard-tool spawn in osc.ts wires it on.
it.skip("(documented hang) without resolveOnExit, await never resolves when daemon inherits stdio", async () => {
const pidFile = join(scriptDir, 'sleeper-skip.pid')
const result = await execFileNoThrow(daemonScript, [pidFile], { timeout: 300 })
trackSleeperPid(pidFile)
expect(result.code).toBe(124)
})
it("settles immediately on 'exit' when resolveOnExit is true, regardless of daemon stdio", async () => {
const pidFile = join(scriptDir, 'sleeper-exit.pid')
const start = Date.now()
const result = await execFileNoThrow(daemonScript, [pidFile], {
timeout: 2000,
resolveOnExit: true
})
trackSleeperPid(pidFile)
const elapsed = Date.now() - start
// The shell exits in a few ms. resolveOnExit lets us return on exit
// (code 0) instead of waiting for the orphaned sleeper to release
// stdio. Should be well under 200ms even on slow CI.
expect(result.code).toBe(0)
expect(elapsed).toBeLessThan(500)
})
it("still surfaces the right code when resolveOnExit'd child exits non-zero", async () => {
const pidFile = join(scriptDir, 'sleeper-fail.pid')
const failScript = join(scriptDir, 'fail.sh')
writeFileSync(failScript, `#!/bin/sh\nsleep 3 &\necho $! > "${pidFile}"\nexit 7\n`)
chmodSync(failScript, 0o755)
const result = await execFileNoThrow(failScript, [], {
timeout: 2000,
resolveOnExit: true
})
trackSleeperPid(pidFile)
expect(result.code).toBe(7)
})
it('settles on timeout=124 when the child itself never exits, even with resolveOnExit', async () => {
const slowScript = join(scriptDir, 'slow.sh')
writeFileSync(slowScript, '#!/bin/sh\nsleep 30\n')
chmodSync(slowScript, 0o755)
const result = await execFileNoThrow(slowScript, [], {
timeout: 200,
resolveOnExit: true
})
// Child process never exits on its own → timer fires → SIGTERM →
// child exits → 'exit' fires with non-null signal. The settle()
// call from the timer registers code=124 first. Either way: 124.
expect(result.code).toBe(124)
})
it('does not double-resolve when both timer and exit fire', async () => {
const pidFile = join(scriptDir, 'sleeper-race.pid')
// Race: child happens to exit right around the timeout. The settled
// guard ensures only the first resolution wins.
const result = await execFileNoThrow(daemonScript, [pidFile], {
timeout: 50, // very tight
resolveOnExit: true
})
trackSleeperPid(pidFile)
// Either code=0 (exit beat timer) or code=124 (timer beat exit).
// Both are valid outcomes; the contract is that the promise settles
// exactly once and doesn't throw.
expect([0, 124]).toContain(result.code)
})
})