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) }) })