fix(channel): make foreach over a Channel survive a close, and not segfault#192
Merged
Merged
Conversation
…gfault
Both bugs sit in the channel iterator, which was never finished to the standard of
send()/recv(). Found while validating the docs: foreach() is the pattern that ought to
be recommended over `catch (ChannelException) { break; }`, because it cannot swallow a
deadlock -- except it did not work.
1. Closing a channel while a consumer is parked in the loop.
channel_iterator_move_forward() parks in channel_wait_for() and is woken with a
ChannelException. It stopped the loop (valid = false) but left that exception pending,
so it escaped foreach() uncaught -- and surfaced against the producer's close(), which
had done nothing wrong. A channel closed *before* the loop starts takes an earlier
branch, which is why the bug stayed invisible.
An explicit close() is how an iteration ends, not a failure, so the exception is now
consumed. Anything else -- a deadlock, a disposal, a cancellation -- is still left
pending and propagates, which is the whole reason to prefer foreach().
2. Segfault when foreach() is the first async operation.
foreach() reaches ce->get_iterator straight from FE_RESET, bypassing send()/recv() and
the ENSURE_COROUTINE_CONTEXT they run. With no main coroutine yet, channel_wait_for()
suspended a NULL coroutine and zend_async_resume_when() dereferenced it. The iterator
now starts the scheduler, like every other blocking channel operation.
tests/channel/070 and 071 fail on the unfixed build (uncaught exception; SIGSEGV).
072 guards the other direction -- it passes either way, and would catch a fix that
swallowed deadlocks along with clean closes.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two bugs in the channel iterator, both found while validating the docs — not from the bug report.
How they surfaced
The docs teach draining a channel like this:
That is a trap:
ChannelExceptionalso carries deadlocks (NO_PRODUCERS,NO_CONSUMERS,DEADLOCK), so the loop silently turns a deadlock into "the stream ended, 0 items".foreachis the pattern that ought to be recommended instead — it cannot swallow a deadlock. Except it did not work.1. Closing a channel while a consumer is parked in the loop
The ordinary producer/consumer shape:
channel_iterator_move_forward()parks inchannel_wait_for()and is woken with aChannelException. It stopped the loop (valid = false) but left that exception pending, so it escapedforeachuncaught — and surfaced against the producer'sclose(), which had done nothing wrong:A channel closed before the loop starts takes an earlier branch (
ZEND_ASYNC_EVENT_IS_CLOSED), which is why this stayed invisible.An explicit
close()is how an iteration ends, not a failure, so the exception is now consumed. Anything else — a deadlock, a disposal, a cancellation — is still left pending and propagates, which is the entire reason to preferforeach.2. Segfault when
foreachis the first async operationforeachreachesce->get_iteratorstraight fromFE_RESET, bypassingsend()/recv()and theENSURE_COROUTINE_CONTEXTthey run. With no main coroutine yet,channel_wait_for()suspended aNULLcoroutine.recv()andsend()at top level are fine — only the iterator path was unguarded.The iterator now starts the scheduler, like every other blocking channel operation.
Tests
070— close during iteration. Fails on the unfixed build with the uncaughtChannelExceptionabove.071—foreachas the first async operation. SIGSEGV on the unfixed build.072— a deadlock must still propagate out offoreach. This one passes either way, by design: it is not a regression test but a guard, and would catch a fix that swallowed deadlocks along with clean closes.Full
ext/async/tests:channel72/72, no new failures overall. The 3 that fail (curl/063,curl/064,io/082) fail identically on a pristine baseline.Noted separately, not addressed here
A channel deadlock leaks 152 bytes (
zend_objects.c:190). Reproduces on a pristine baseline with a plainrecv(), so it is independent of this change.