Files
pnpm/pnpm11/__utils__/scripts/terminal.py
Zoltan Kochan c4f877990c fix(exec): wait for the command after Ctrl+C and relay signals to it (#15121)
pnpm exec and pnpm dlx spawned the command through execa and handled
no signal themselves. A Ctrl+C ended pnpm at once, and execa's exit
cleanup then sent the command a SIGTERM while it was still handling
the terminal's SIGINT, so it never finished shutting down. A signal
sent to pnpm alone never reached the command at all.

The signal relay of the lifecycle runner is now a shared module of
`@pnpm/exec.npm-lifecycle`, and the tracked execa wrapper uses it: the
command gets a process group of its own when pnpm has no terminal,
pnpm relays SIGTERM and a SIGINT it received alone, escalates a second
interrupt to SIGTERM, and waits for the command and its group before
going on. The runner's own relay is unchanged in behavior. Terminating a
still-running child when pnpm exits is an option of the relay: the runner
always takes it, exec and dlx only for a detached command, since execa's
own cleanup terminates an attached one.

A recursive run that a signal reaches stops dispatching: queued
commands never start, a command the signal cut short is not journaled
as passed, and once every running command has settled pnpm re-raises
the signal on itself, so the shell sees an interrupted run. A single
command's exit status stays pnpm's.

Related to pnpm/pnpm#7374
2026-09-19 19:52:04 +02:00

73 lines
2.0 KiB
Python

# Runs a command as the foreground job of a pseudo-terminal, presses Ctrl+C
# once the command prints "started", and echoes everything the command wrote.
#
# A leading --deadline=SECONDS bounds the wait for "started" (30 by default),
# and the command then has 30 seconds to exit after the Ctrl+C. When either
# passes, the command and everything it started are killed and the exit
# status is 124, so a test fails instead of hanging on a command that never
# became ready or never shut down. A SIGTERM from the caller's own timeout
# kills them the same way, so the command never outlives the helper.
import os
import pty
import select
import signal
import sys
import time
SHUTDOWN_SECONDS = 30
argv = sys.argv[1:]
deadline_seconds = 30
if argv and argv[0].startswith('--deadline='):
deadline_seconds = float(argv.pop(0).removeprefix('--deadline='))
pid, fd = pty.fork()
if pid == 0:
os.execvp(argv[0], argv)
def kill_command():
# The command leads the pseudo-terminal's session, so its process group
# is everything it started.
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
def on_terminate(signum, frame):
kill_command()
sys.exit(128 + signum)
signal.signal(signal.SIGTERM, on_terminate)
output = b''
deadline = time.time() + deadline_seconds
interrupted = False
timed_out = False
while True:
if time.time() >= deadline:
timed_out = True
break
ready, _, _ = select.select([fd], [], [], 0.1)
if ready:
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
output += data
if not interrupted and b'started' in output:
os.write(fd, b'\x03')
interrupted = True
deadline = time.time() + SHUTDOWN_SECONDS
if timed_out:
kill_command()
_, status = os.waitpid(pid, 0)
sys.stdout.write(output.decode(errors='replace'))
if timed_out:
sys.exit(124)
sys.exit(os.waitstatus_to_exitcode(status) if os.WIFEXITED(status) else 128 + os.WTERMSIG(status))