Skip to content

Commit 934b1ec

Browse files
committed
Fix await_*() deadlock with already-completed awaitables
When a coroutine or Future had already completed before being passed to await_all/await_any_or_fail/etc, it was skipped (CLOSED → continue) but resolved_count was never incremented, so it could never reach total → deadlock. Fixed by using ZEND_ASYNC_EVENT_REPLAY to replay the stored result/exception through the normal callback path. Also breaks out of the loop early when replay already satisfies the waiting condition, so await_any_or_fail returns immediately instead of subscribing to remaining awaitables and suspending unnecessarily.
1 parent 093623a commit 934b1ec

7 files changed

Lines changed: 227 additions & 1 deletion

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- **Non-blocking `flock()`**: `flock()` no longer blocks the event loop. The lock operation is offloaded to the libuv thread pool via `zend_async_task_t`, allowing other coroutines to continue executing while waiting for a file lock.
1212
- **`zend_async_task_new()` API**: New factory function for creating thread pool tasks, registered through the reactor like timer and IO events. Replaces manual `pecalloc` + field initialization.
1313

14+
### Fixed
15+
- **`await_*()` deadlock with already-completed awaitables**: When a coroutine or Future passed to `await_all()`, `await_any_or_fail()`, or other `await_*()` functions had already completed, it was skipped entirely (`ZEND_ASYNC_EVENT_IS_CLOSED``continue`), but `resolved_count` was never incremented. Since `total` still counted the skipped awaitable, `resolved_count` could never reach `total`, causing a deadlock. Fixed by using `ZEND_ASYNC_EVENT_REPLAY` to synchronously replay the stored result/exception through the normal callback path, correctly updating all counters. Additionally, when replay satisfies the waiting condition early (e.g. `await_any_or_fail` needs only one result), the loop now breaks immediately instead of subscribing to remaining awaitables and suspending unnecessarily.
16+
1417
## [0.6.1] - 2026-03-15
1518

1619
### Fixed

async_API.c

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ void async_await_futures(zval *iterable,
968968
return;
969969
}
970970

971-
if (awaitable == NULL || ZEND_ASYNC_EVENT_IS_CLOSED(awaitable)) {
971+
if (awaitable == NULL) {
972972
continue;
973973
}
974974

@@ -999,6 +999,21 @@ void async_await_futures(zval *iterable,
999999
}
10001000
}
10011001

1002+
/* If the awaitable already completed, replay its stored result/exception
1003+
* through the normal callback path so counters update correctly.
1004+
* Without this, resolved_count never reaches total → deadlock. */
1005+
if (ZEND_ASYNC_EVENT_IS_CLOSED(awaitable)) {
1006+
callback->callback.base.dispose = async_waiting_callback_dispose;
1007+
await_context->ref_count++;
1008+
ZEND_ASYNC_EVENT_REPLAY(awaitable, &callback->callback.base);
1009+
1010+
if (AWAIT_ITERATOR_IS_FINISHED(await_context)) {
1011+
break;
1012+
}
1013+
1014+
continue;
1015+
}
1016+
10021017
zend_async_resume_when(coroutine, awaitable, false, NULL, &callback->callback);
10031018

10041019
if (UNEXPECTED(EG(exception))) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
--TEST--
2+
await_all() with already completed coroutine does not deadlock
3+
--FILE--
4+
<?php
5+
6+
use function Async\spawn;
7+
use function Async\await_all;
8+
use function Async\suspend;
9+
10+
echo "start\n";
11+
12+
$completed = spawn(function() {
13+
echo "completed: done\n";
14+
return "already_done";
15+
});
16+
17+
// Let it finish
18+
suspend();
19+
20+
echo "completed: " . ($completed->isCompleted() ? "true" : "false") . "\n";
21+
22+
$pending = spawn(function() {
23+
echo "pending: done\n";
24+
return "just_finished";
25+
});
26+
27+
[$results, $errors] = await_all([$completed, $pending]);
28+
29+
echo "results[0]: " . $results[0] . "\n";
30+
echo "results[1]: " . $results[1] . "\n";
31+
echo "errors: " . count($errors) . "\n";
32+
echo "end\n";
33+
34+
?>
35+
--EXPECT--
36+
start
37+
completed: done
38+
completed: true
39+
pending: done
40+
results[0]: already_done
41+
results[1]: just_finished
42+
errors: 0
43+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--TEST--
2+
await_all() with already completed Future does not deadlock
3+
--FILE--
4+
<?php
5+
6+
use Async\FutureState;
7+
use Async\Future;
8+
use function Async\spawn;
9+
use function Async\await_all;
10+
11+
echo "start\n";
12+
13+
$state = new FutureState();
14+
$state->complete("future_result");
15+
$completed_future = new Future($state);
16+
17+
$pending = spawn(function() {
18+
echo "pending: done\n";
19+
return "coroutine_result";
20+
});
21+
22+
[$results, $errors] = await_all([$completed_future, $pending]);
23+
24+
echo "results[0]: " . $results[0] . "\n";
25+
echo "results[1]: " . $results[1] . "\n";
26+
echo "errors: " . count($errors) . "\n";
27+
echo "end\n";
28+
29+
?>
30+
--EXPECT--
31+
start
32+
pending: done
33+
results[0]: future_result
34+
results[1]: coroutine_result
35+
errors: 0
36+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
--TEST--
2+
await_all() with already completed coroutine that threw exception does not deadlock
3+
--FILE--
4+
<?php
5+
6+
use function Async\spawn;
7+
use function Async\await;
8+
use function Async\await_all;
9+
10+
echo "start\n";
11+
12+
$failed = spawn(function() {
13+
throw new RuntimeException("already failed");
14+
});
15+
16+
// Await to let it finish and handle the exception
17+
try {
18+
await($failed);
19+
} catch (RuntimeException $e) {
20+
echo "caught: " . $e->getMessage() . "\n";
21+
}
22+
23+
echo "failed: " . ($failed->isCompleted() ? "true" : "false") . "\n";
24+
25+
$pending = spawn(function() {
26+
echo "pending: done\n";
27+
return "success";
28+
});
29+
30+
[$results, $errors] = await_all([$failed, $pending]);
31+
32+
echo "results count: " . count($results) . "\n";
33+
echo "results[1]: " . $results[1] . "\n";
34+
echo "errors count: " . count($errors) . "\n";
35+
echo "error[0]: " . $errors[0]->getMessage() . "\n";
36+
echo "end\n";
37+
38+
?>
39+
--EXPECT--
40+
start
41+
caught: already failed
42+
failed: true
43+
pending: done
44+
results count: 1
45+
results[1]: success
46+
errors count: 1
47+
error[0]: already failed
48+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
--TEST--
2+
await_all() with all awaitables already completed does not deadlock
3+
--FILE--
4+
<?php
5+
6+
use function Async\spawn;
7+
use function Async\await;
8+
use function Async\await_all;
9+
10+
echo "start\n";
11+
12+
$a = spawn(function() {
13+
return "aaa";
14+
});
15+
16+
$b = spawn(function() {
17+
return "bbb";
18+
});
19+
20+
// Let both finish
21+
await($a);
22+
await($b);
23+
24+
echo "a completed: " . ($a->isCompleted() ? "true" : "false") . "\n";
25+
echo "b completed: " . ($b->isCompleted() ? "true" : "false") . "\n";
26+
27+
[$results, $errors] = await_all([$a, $b]);
28+
29+
echo "results[0]: " . $results[0] . "\n";
30+
echo "results[1]: " . $results[1] . "\n";
31+
echo "errors: " . count($errors) . "\n";
32+
echo "end\n";
33+
34+
?>
35+
--EXPECT--
36+
start
37+
a completed: true
38+
b completed: true
39+
results[0]: aaa
40+
results[1]: bbb
41+
errors: 0
42+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
--TEST--
2+
await_any_or_fail() with already completed coroutine returns immediately
3+
--FILE--
4+
<?php
5+
6+
use function Async\spawn;
7+
use function Async\await;
8+
use function Async\await_any_or_fail;
9+
use function Async\suspend;
10+
11+
echo "start\n";
12+
13+
$completed = spawn(function() {
14+
return "fast";
15+
});
16+
17+
// Let it finish
18+
await($completed);
19+
20+
echo "completed: " . ($completed->isCompleted() ? "true" : "false") . "\n";
21+
22+
// This coroutine will never finish on its own
23+
$slow = spawn(function() {
24+
suspend();
25+
suspend();
26+
suspend();
27+
return "slow";
28+
});
29+
30+
$result = await_any_or_fail([$completed, $slow]);
31+
echo "result: " . $result . "\n";
32+
echo "end\n";
33+
34+
?>
35+
--EXPECT--
36+
start
37+
completed: true
38+
result: fast
39+
end

0 commit comments

Comments
 (0)