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
73 lines
2.0 KiB
Python
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))
|