Files
aiturk-hermes-ide/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
T

160 lines
5.9 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', () => {
// Formerly a documented forever-hang: without resolveOnExit, the 'close'
// event doesn't fire when the immediate child has exited but a forked
// daemon still holds stdio open, and even the SIGTERM at timeout used to
// leave the promise unsettled (#93134). The unconditional settle(124) in
// the timeout handler now guarantees the await resolves with 124.
// The daemon script's sleeper lives 30s so it genuinely outlives the
// timeout (and vitest's own 5s test timeout — before the fix this test
// fails by timing out, not by asserting).
it('settles with code=124 on timeout when a daemon inherits stdio and resolveOnExit is off', async () => {
const pidFile = join(scriptDir, 'sleeper-skip.pid')
const longDaemonScript = join(scriptDir, 'fake-daemonizer-long.sh')
writeFileSync(longDaemonScript, '#!/bin/sh\nsleep 30 &\necho $! > "$1"\nexit 0\n')
chmodSync(longDaemonScript, 0o755)
const start = Date.now()
const result = await execFileNoThrow(longDaemonScript, [pidFile], { timeout: 300 })
trackSleeperPid(pidFile)
expect(result.code).toBe(124)
expect(Date.now() - start).toBeLessThan(2000)
})
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)
})
})