Skip to content

Fix proc_close() signal exit code handling - #21293

Open
alies-dev wants to merge 4 commits into
php:masterfrom
alies-dev:fix-proc-close-signal-exit-code
Open

alies-dev wants to merge 4 commits into
php:masterfrom
alies-dev:fix-proc-close-signal-exit-code

Conversation

@alies-dev

@alies-dev alies-dev commented Feb 25, 2026 •

Copy link
Copy Markdown

When a child process spawned via proc_open() is killed by a signal, proc_close() returns a raw/mangled waitpid status instead of a meaningful exit code. This makes it impossible to distinguish between "exited normally with code 11" and "killed by SIGSEGV".

The fix adds proper WIFSIGNALED()/WTERMSIG() handling in proc_open_rsrc_dtor() (which proc_close() calls internally), returning 128 + signal_number — the same convention used by bash and other shells.

proc_get_status() already handled this correctly via its signaled/termsig fields; only the proc_close() return value was broken.

Fixes GH-21292


The bug originally was found by using Psalm vimeo/psalm#11679 that uses composer/xdebug-handler: composer/xdebug-handler#161

?>
--EXPECT--
int(137)
int(42) No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: eol before eof

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thanks!

This made me curious -- the .editorconfig already has insert_final_newline = true, but it's not enforced in CI or on pre-commit hook level. A simple check could catch this automatically. Two options that fit the repo's style:

  1. Shell one-liner in CI (zero deps):
    git ls-files -z '*.phpt' '*.php' '*.c' '*.h' | xargs -0 -L1 sh -c 'test -z "$(tail -c1 "$0")" || echo "missing final newline: $0"'

  2. editorconfig-checker — single Go binary, validates all .editorconfig rules at once. Used by several large projects (e.g., Kotlin).

Given how minimal the current CI tooling is, option 1 would probably be the least friction.

Please let me know what do you think -- I can create a PR

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember all the details, but at one point we tried to run Clang Format but it produces a large diff and apparently also altered the behaviour of code. But the first one is probably the most sensible as a base rule?
Although please provide the alternative when you open a PR as others might have a different opinion. :)

@Girgias
Girgias requested a review from devnexen February 26, 2026 15:08
@bukka

bukka commented Feb 28, 2026

Copy link
Copy Markdown
Member

Seems like BC break to me...

@andypost

andypost commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

does it need RFC?

alies-dev and others added 4 commits October 13, 2026 12:21
proc_close() previously returned the raw waitpid status when a child
process was killed by a signal, making it impossible to distinguish
a signal death from a normal exit with the same code.

Now follows the bash convention: return 128 + signal_number for
signal-terminated processes (e.g. 139 for SIGSEGV, 143 for SIGTERM).

Closes phpGH-21292
PHP catches SIGTERM internally and does exit(255), so the child
process never actually dies from the signal. Use /bin/sh with
kill -9 (SIGKILL) which cannot be caught.
…BC change

proc_get_status() reaping a signaled child left proc_close() with ECHILD
and a -1 return value. Cache terminal signal statuses the same way as
normal exits, rename the test to gh21292.phpt and cover that case.

Add UPGRADING and NEWS entries.
@alies-dev
alies-dev force-pushed the fix-proc-close-signal-exit-code branch from bd731e7 to 0f49bb0 Compare October 13, 2026 11:40
@alies-dev

alies-dev commented Oct 13, 2026 •

Copy link
Copy Markdown
Author

@bukka yes, it does change behavior. So master only, not for 8.4/8.5. Rebased on master and added UPGRADING (BC section) and NEWS entries.

While today looking into it again I found one more case: if proc_get_status() reaps a signaled child first, proc_close() then gets ECHILD and returns -1. GH-10239 fixed exactly that for normal exits, but not for signals. So waitpid_cached() now caches the signaled status too. This also fixes repeated proc_get_status() calls, which reported signaled => false, termsig => 0 after the first one. cached becomes true for those children as well, hence the expectation changes in bug39322.phpt and proc_open02.phpt. Renamed the test to gh21292.phpt and added that case there.

pclose() has the same raw status decoding (plain_wrapper.c), and exec()/system() go through it too. I didn't touch it here since it would be one more BC change, but can do a follow-up if you think it makes sense.

@andypost

does it need RFC?

GH-10239 (#10250) also changed proc_close()'s return value on master during 8.3 development, with NEWS and UPGRADING entries and no RFC. Though that one fixed an error value (-1), and here a non-error value changes (e.g. SIGKILL: 9 -> 137), so it's not quite the same. The release policy says fixing clearly incorrect behavior "is not automatically considered a BC break", but the impact should be evaluated. I'd expect it to be small, since code relying on the old raw value can't tell a signal from a normal exit anyway, and proc_get_status() has signaled/termsig for anyone who needs to be exact (137 can still be a real exit(137)). Happy to post a heads-up on internals if you think it's worth it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

proc_close() returns raw signal number, indistinguishable from normal exit code

5 participants