How do you stop an external command that PHP started with proc_open() when it runs too long, and what does proc_terminate() actually do?
answer
- exec family cannot time out
- poll with stream_select and proc_get_status
- SIGTERM (15) by default
- returns immediately, bool
- string command: the pid may be a shell
basics
~20 sStart the tool with proc_open(), poll its pipes with stream_select() and proc_get_status() against a deadline, then call proc_terminate(). It sends SIGTERM by default and returns immediately with a bool; wait briefly, send SIGKILL (9) if still running, then proc_close().
solid answer
~40 s`exec()` and friends block until the command exits, so they cannot enforce a deadline. With `proc_open()` I keep control: non-blocking pipes, a `stream_select()` loop with a short timeout, and `proc_get_status()['running']` checked against a deadline from `hrtime()`. When time runs out I call `proc_terminate($process)`. It sends signal 15, SIGTERM, by default, via `kill()`, and **returns immediately**. It does not wait, and it returns `true` if the signal was sent, not whether the process died. A well-behaved tool exits; a stuck one may ignore SIGTERM. So after a short grace period I check `running` again and call `proc_terminate($process, 9)` for SIGKILL. Then I close the pipes and `proc_close()`. With an array command the pid is the tool itself. With a string command it is `/bin/sh`, and the tool may survive as an orphan.
code
php · 28 lines<?php
declare(strict_types=1);
$pdf = '/var/app/uploads/report.pdf';
$png = '/var/app/thumbs/report.png';
$process = proc_open(['pdfthumb', '--', $pdf, $png], [1 => ['null'], 2 => ['pipe', 'w']], $pipes);
stream_set_blocking($pipes[2], false);
$deadline = hrtime(true) + 10_000_000_000; // 10 s
$stderr = '';
while (proc_get_status($process)['running']) {
if (hrtime(true) > $deadline) {
proc_terminate($process); // SIGTERM, returns at once
usleep(1_000_000); // grace period
if (proc_get_status($process)['running']) {
proc_terminate($process, 9); // SIGKILL
}
break;
}
$read = [$pipes[2]];
$write = $except = null;
if (stream_select($read, $write, $except, 0, 200_000)) {
$stderr .= (string) fread($pipes[2], 8192);
}
}
$stderr .= (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);go deeper
Know that the exec family waits until the command finishes, and that proc_open() plus proc_terminate() is how you stop a runaway process.
Explain the polling loop with stream_select() and proc_get_status(), proc_terminate()'s default SIGTERM, and that it returns without waiting.
Implement escalation from SIGTERM to SIGKILL, avoid the shell-as-parent orphan with array commands, drain pipes while waiting, and log signaled exits distinctly.
Decide which tools may run inline versus in background jobs, and set time and resource budgets per tool so one bad input cannot exhaust the worker pool.
## Why you need this External tools hang. A malformed PDF can send a renderer into a loop, or a network-mounted file can stall a read. In a web request that is a stuck PHP worker, and in a queue consumer it is a job that never ends. `max_execution_time` does not reliably help: on typical Linux builds it measures the script's own CPU time, and a PHP process waiting on a child uses almost none. The fix is an explicit deadline, which requires `proc_open()`: `exec()`, `system()`, `passthru()` and `shell_exec()` only return when the command finishes. ## The monitoring loop With `$process = proc_open([...], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes)`: 1. Record a deadline, for example `hrtime(true) + 10 * 1_000_000_000` nanoseconds. 2. Put the pipes in non-blocking mode with `stream_set_blocking($pipe, false)`. 3. Loop: `stream_select()` on the open pipes with a short timeout (say 200 ms), read what is ready, then check `proc_get_status($process)['running']`. 4. Exit the loop when the process has stopped, or when the deadline passes. Draining the pipes inside the loop is not optional. A child blocked on a full pipe looks exactly like a hung child. ## What proc_terminate() does `proc_terminate($process, int $signal = 15): bool`. On POSIX systems PHP calls `kill(pid, $signal)` and returns whether that call succeeded. The manual stresses that it **returns immediately and does not wait** for the process to terminate. On Windows it calls `TerminateProcess()` and the signal argument is ignored. | Step | Call | What it means | |---|---|---| | polite stop | `proc_terminate($process)` | SIGTERM (15): the program may clean up and exit | | grace period | poll `proc_get_status()` for a second or two | give it time to exit | | forced stop | `proc_terminate($process, 9)` | SIGKILL: cannot be caught or ignored | | reap | close pipes, then `proc_close($process)` | wait for exit and collect the status | The named constants `SIGTERM` and `SIGKILL` come from the pcntl extension, which is often available only in CLI builds, so web code frequently passes the numbers `15` and `9`. After a kill, `proc_get_status()` reports `signaled => true` and `termsig` with the signal number. Record that alongside the timeout, so logs distinguish "the tool failed" from "we killed it". ## The string-command trap With a string command, PHP starts `/bin/sh -c "..."`, and the pid in the process resource is the **shell's**. Some shells replace themselves with a single simple command, but a command with pipes or `&&` keeps the shell as the parent. Signalling the shell may then leave the real tool running as an orphan, still burning CPU, and possibly still holding your pipes open, so reads never see end-of-file. Two ways out: - use an **array command**, so the pid is the tool itself; - or prefix a single command with `exec` inside the string (`'exec pdfthumb ...'`), so the shell replaces itself with the tool. Killing whole process trees, process groups and signal semantics are operating-system topics beyond these functions. ## Testing the timeout path A timeout branch that never runs in development is a timeout branch that is broken in production. Exercise it with a stand-in command that sleeps longer than the budget, for example the PHP binary itself (`[PHP_BINARY, '-r', 'sleep(30);']`), and assert that the call returns within the budget plus the grace period, that `proc_get_status()` reports the process as signaled, and that no partial output file is left behind. ## Putting it in context - **Choose the budget from the caller.** A thumbnail generated during an upload request might get 10 seconds; the same work in a background job can have more. - **Clean up partial output.** A killed renderer may leave a truncated PNG; delete it rather than serving it. - **Consider moving work off the request.** If a tool regularly approaches its limit, queue the job and show a placeholder thumbnail until it finishes. - **Limit resources as well as time.** A timeout does not cap memory; operating-system limits or a container boundary do that.
- Why doesn't proc_terminate() returning true prove the process has stopped?`proc_terminate()` only sends a signal with `kill()` and returns immediately; `true` means the signal was delivered. The program may still be cleaning up, or may ignore SIGTERM entirely. Poll `proc_get_status()['running']`, escalate to signal 9 if needed, and let `proc_close()` wait for the exit.
- Why might a tool keep running after proc_terminate() on a string command?With a string command, PHP runs `/bin/sh -c`, so the process handle's pid can be the shell rather than the tool. Signalling the shell does not necessarily reach its child, which then keeps running as an orphan. Use an array command, or `exec` the tool inside the string, so the pid is the tool's own.
- Why can't exec() enforce a timeout on the command it runs?`exec()` blocks until the command exits and gives PHP no handle to the running process, so there is nothing to poll or signal. Only `proc_open()` returns a process resource that `proc_get_status()` and `proc_terminate()` can act on while the command runs.
saying these in an interview costs you the question
- proc_terminate() waits until the process has exited.
- proc_terminate() sends SIGKILL by default.
- max_execution_time on a typical Linux build stops a PHP script waiting on a hung child.
- Signalling the pid of a string command always stops the actual tool.
- A process that received SIGTERM is guaranteed to exit.