Skip to content

#154: ThreadPool/ThreadChannel — $this/method-as-closure coverage + fix worker crash on exit()/die()#155

Merged
EdmondDantes merged 4 commits into
mainfrom
154-threadpoolthreadchannel-no-test-coverage-for-transferring-this-bound-closures-incl-method-as-closure
May 30, 2026
Merged

#154: ThreadPool/ThreadChannel — $this/method-as-closure coverage + fix worker crash on exit()/die()#155
EdmondDantes merged 4 commits into
mainfrom
154-threadpoolthreadchannel-no-test-coverage-for-transferring-this-bound-closures-incl-method-as-closure

Conversation

@EdmondDantes
Copy link
Copy Markdown
Contributor

Closes #154.

Summary

Two real fixes plus the missing $this test coverage that started the investigation.

Fixes (thread_pool.c)

  1. Worker crash on exit()/die() in a task or bootloader. A graceful exit()/die() threw an unwind-exit token that the worker either passed to reject() or re-raised via zend_bailout() — both crash the worker fiber ("Error transfer requires a throwable value", assert + ASAN UAF at zend_fibers.c:491). The worker now runs the task/bootloader call under zend_try and inspects zend_is_unwind_exit()/zend_is_graceful_exit():
    • exit()/die() in a task is graceful "this task is done" — the worker's request survives, so the future resolves to null and the worker keeps serving the next task (no respawn needed; verified mixing exit()/throw/normal on one worker).
    • A real fatal (OOM zend_bailout) or exit()/die() in the bootloader delivers an Async\ThreadTransferException to pending awaiters and tears the pool down.
  2. $this-bound bootloader transfer error was swallowed. When a bootloader bound to $this of a class missing on the worker (or whose body threw) failed, the real error became an E_WARNING and tasks were rejected with a generic cancellation. thread_pool_drain_tasks gained a reject_with parameter so the actual cause now reaches every awaiter.

Test coverage

The $this / method-as-closure transfer matrix existed only for spawn_thread. Added it for ThreadPool::submit() and ThreadChannel::send():

  • thread_pool/042–059, thread_channel/038–039: base, private/protected, self-cycle, readonly, enum (instance + property), WeakReference, typed props, method dispatch, first-class callable ($this->m(...), $obj->m(...)), Closure::fromCallable, Closure::bind, arrow-fn, nested closure, static first-class, missing-class error, $this+use().
  • thread_pool/060,063: bootloader transfer-error / thrown-exception propagation.
  • thread_pool/061,062,064: exit()/die() in bootloader / task — clean behaviour, no crash, worker survives.

Testing

Full tests/thread*, tests/thread_pool, tests/thread_channel suites green under ZTS + ASAN/UBSAN (174 passed, 0 failed).

Notes

  • Coroutine-mode task exit() already resolves the future to null via the normal path — consistent with the new sync-mode behaviour.
  • No php-src/core changes — ext/async only.

…ootloader errors, add $this coverage

Worker-level fixes in thread_pool.c:
- exit()/die() (unwind-exit token) or a fatal error (bailout) in a task or the
  bootloader crashed the worker fiber ("Error transfer requires a throwable
  value" assert + ASAN UAF at zend_fibers.c:491). The worker now runs the call
  under zend_try and checks zend_is_unwind_exit/zend_is_graceful_exit, converts
  it into an Async\ThreadTransferException delivered to awaiters, and exits
  cleanly instead of re-raising zend_bailout() into the fiber.
- A $this-bound bootloader that can't load on the worker (or whose body threw)
  had its real error turned into an E_WARNING and discarded, with tasks rejected
  by a generic "cancelled before execution". thread_pool_drain_tasks gained a
  reject_with parameter so the actual cause now reaches every awaiter.

Tests:
- thread_pool/042-059, thread_channel/038-039: full $this + method-as-closure
  transfer matrix for submit()/send() (was only covered for spawn_thread).
- thread_pool/060,063: bootloader transfer-error / thrown-exception propagation.
- thread_pool/061,062: exit()/die() in bootloader / sync task -> clean
  ThreadTransferException, no crash.

Known follow-up: coroutine-mode task exit() resolves the future to null
(graceful success); surfacing worker-exit separately is tracked for respawn work.
…er keeps serving

Follow-up to the crash fix: a graceful exit()/die() in a task is "this task is
done", not a fatal — the worker's request survives it. Empirically a single
worker keeps serving the next task after one exits (verified mixing exit() with
throwing and normal tasks). So a task exit() now resolves that task's future to
null and the worker continues, instead of delivering ThreadTransferException and
tearing the pool down. No respawn machinery is needed since the worker never
actually dies.

A real fatal (zend_bailout, e.g. OOM) still tears the pool down via
ThreadTransferException — the request is unusable. Bootloader exit() likewise
remains a hard pool failure (setup did not complete).

Reverted the coroutine-mode dispose unwind-token guard: coroutine-mode exit
already resolves the future to null through the normal path, so the guard was
dormant and inconsistent with the null model.

Tests: 062 now asserts exit -> null + next task runs; new 064 stresses a single
worker with exit/throw/normal interleaved.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 30, 2026

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

…rror

CI (LINUX_X64_DEBUG_ZTS, non-ASAN) caught a SEGV in 060: the bootloader
transfer-failed path deep-copied the raw 'Cannot load transferred object'
Error across the thread boundary to the awaiter. That Error is thrown from
inside the cross-thread transfer machinery and its backtrace reaches into
worker-local load state, so loading it on the parent thread crashes (race,
only reproducible under real multi-core scheduling — ASAN's heap layout
masked it). Now we re-ship only the message as a clean ThreadTransferException,
matching the bailout and spawn_thread paths. 060 green 10/10 on non-ASAN ZTS.
@EdmondDantes EdmondDantes merged commit 0d445d9 into main May 30, 2026
9 checks passed
@EdmondDantes EdmondDantes deleted the 154-threadpoolthreadchannel-no-test-coverage-for-transferring-this-bound-closures-incl-method-as-closure branch May 30, 2026 15:39
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.

ThreadPool/ThreadChannel: no test coverage for transferring $this-bound closures (incl. method-as-closure)

1 participant