Skip to content

fix(channel): make foreach over a Channel survive a close, and not segfault#192

Merged
EdmondDantes merged 1 commit into
mainfrom
channel-foreach-swallows-close-exception
Jul 14, 2026
Merged

fix(channel): make foreach over a Channel survive a close, and not segfault#192
EdmondDantes merged 1 commit into
mainfrom
channel-foreach-swallows-close-exception

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

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:

while (true) {
    try { $v = $ch->recv(); }
    catch (ChannelException) { break; }
    process($v);
}

That is a trap: ChannelException also carries deadlocks (NO_PRODUCERS, NO_CONSUMERS, DEADLOCK), so the loop silently turns a deadlock into "the stream ended, 0 items". foreach is 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:

$consumer = spawn(fn() => array_map(..., iterator_to_array($channel)));   // foreach inside
$producer = spawn(function () use ($channel) {
    $channel->send('a');
    $channel->close();
});

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:

Fatal error: Uncaught Async\ChannelException: Channel is closed
Stack trace:
#0 Command line code(10): Async\Channel->close()

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 prefer foreach.

2. Segfault when foreach is the first async operation

$ch = new Async\Channel(1);
foreach ($ch as $v) {}     // SIGSEGV
#0 zend_async_resume_when (coroutine=0x0, …)   Zend/zend_async_API.c:1187
#1 channel_wait_for                            ext/async/channel.c:565
#2 channel_iterator_move_forward               ext/async/channel.c:798
#3 channel_iterator_rewind
#4 zend_fe_reset_iterator                      Zend/zend_execute.c:5403

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. recv() and send() 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 uncaught ChannelException above.
  • 071foreach as the first async operation. SIGSEGV on the unfixed build.
  • 072 — a deadlock must still propagate out of foreach. 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: channel 72/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 plain recv(), so it is independent of this change.

…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

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@EdmondDantes EdmondDantes merged commit 0d29d3e into main Jul 14, 2026
9 checks passed
@EdmondDantes EdmondDantes deleted the channel-foreach-swallows-close-exception branch July 14, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant