Keep pending promises in the coroutine that created them

Merged
BI
9d ago
+842-63

This turns the fork into hypervel/guzzle-promises: a drop-in replacement for guzzlehttp/promises that is safe to use when many Swoole coroutines share one PHP process.

The problem

Hypervel runs applications in long-lived Swoole workers. Every request runs in its own coroutine, and they all share the same process. Guzzle's promises were written for a single flow of execution, so nothing stops one coroutine from waiting on, chaining onto, cancelling or settling a promise that another coroutine is still working on. When that happens the work runs in the wrong coroutine: callbacks see another request's context, a wait() drives a transfer that belongs to someone else, and a cancel() tears down work its owner still needs. These bugs depend on timing and are very hard to track down.

Until now Hypervel blocked this with AOP aspects wrapped around Promise. That worked, but it meant generating proxy classes for a third-party package and sending every promise call through the aspect layer. Putting the rule in the library itself is simpler, cheaper, and covers every promise, including the ones SDKs create.

What changes

Ownership. A Promise now remembers the coroutine that created it (-1 outside coroutines). While it's unfinished, only that coroutine may call then(), otherwise(), wait(), cancel(), resolve() or reject(). Anyone else gets a LogicException, thrown before anything changes:

Coroutine 7 cannot call wait() on an unfinished promise owned by coroutine 3. Create and finish the operation in the same coroutine, then share its settled result.

Once a promise, and every promise it was resolved with, has settled, any coroutine can chain on it or wait for it, so sharing finished results keeps working.

Adoption. Resolving a promise with another promise is where this gets subtle, so settle() checks before it touches any state. It refuses another coroutine's unfinished Promise or Coroutine. A private walk follows settled promises to whatever they were resolved with and looks inside a Coroutine through a new internal _getResult(). When a chain ends in a pending promise from another library, the walk reports the last Promise before it, so that promise's owner decides. Adopting another library's pending promise directly is still allowed, as upstream does.

Coroutine cancellation. Coroutine::cancel() cancels its current promise before its result, and that current promise can belong to another library. It now asks a new internal Promise::_assertOwner() to refuse other coroutines first, so a refused cancel never reaches that library.

Scoped task queues. The new ScopedTaskQueueInterface extends TaskQueueInterface with track(PromiseInterface $promise). A queue that runs tasks per coroutine can implement it, and code that starts work that outlives a single call, like an HTTP transfer, passes its promise to track(). The work is then cancelled when its coroutine ends instead of being left running. Nothing here implements the interface and Utils::queue() is unchanged. It's meant for Hypervel's per-coroutine task queue, which Hypervel's Guzzle fork uses to track pending cURL multi transfers.

Compatibility

Composer installs this package in place of guzzlehttp/promises, so it ends up inside third-party packages and SDKs that know nothing about Hypervel. The public API is untouched: no class, method, signature or interface changed. The additions are one new interface and two @internal methods.

Outside coroutines everything behaves exactly like upstream, since every promise there has the same owner, and upstream's whole test suite passes unchanged. Inside coroutines the only difference is the ownership rule, which is the same rule Hypervel already enforced through the aspects.

Package and tooling

  • composer.json: renamed to hypervel/guzzle-promises, replaces guzzlehttp/promises at self.version, and requires PHP 8.4+ and ext-swoole. The code stays PHP 7.4 compatible so upstream's static analysis setup keeps working as is.
  • CI runs the tests on PHP 8.4 and 8.5 (lowest dependencies on 8.4) inside Hypervel's components CI image, which has Swoole. PHPStan, PHP-CS-Fixer and Composer Normalize stay on PHP 7.4 like upstream, and the steps that install the root package ignore the php and ext-swoole requirements. That's set per step on purpose: ignoring the PHP version for the PHP-CS-Fixer tool install pulls in symfony/console releases that don't parse on 7.4.
  • CHANGELOG.md stays upstream's. Fork changes are recorded here, in the README's Hypervel Fork section and in release notes.
  • AGENTS.md gets the fork rules, starting with the API compatibility requirement.

Tests

The new tests in PromiseTest and CoroutineTest run in real Swoole coroutines:

  • Foreign then, wait, cancel, resolve and reject throw and leave the promise's state, result, handlers and wait and cancel functions untouched, and the owner can still finish it.
  • Settled promises can be chained and waited on from other coroutines.
  • A foreign same-value resolve() of a settled promise is still a no-op, and conflicting calls keep upstream's errors.
  • A promise resolved with a pending promise stays owned until that promise settles.
  • Adopting another coroutine's pending Promise, its pending Coroutine, or its Promise that waits on a pending third-party promise is refused before anything changes.
  • Adopting a pending third-party promise directly works, and other coroutines can use the result once it settles.
  • Promises created outside coroutines can't be settled inside one.
  • A foreign Coroutine::cancel() never reaches a third-party current promise, and the owner's cancel still does.

Each ownership check is load-bearing: removing any one of them makes the matching test fail. The PromiseTest cases get their own task queue, because a coroutine that drains the shared global queue would also run tasks that other tests left behind, and those settle promises owned outside coroutines.

Verified locally:

  • PHPUnit (235 tests) in the CI image on PHP 8.4 and 8.5, and with --prefer-lowest on 8.4.
  • Composer Normalize, PHPStan and PHP-CS-Fixer under PHP 7.4, the way static.yml runs them.

Summary by cubic

Forks Guzzle Promises as hypervel/guzzle-promises to keep Swoole coroutine work in the coroutine that started it. Pending promises are now restricted to their owner, while settled results remain shareable.

Coroutine safety

  • Rejects calls from another coroutine before changing a promise's state or handlers.
  • Checks ownership when adopting pending promises or coroutines, and prevents a foreign cancellation from reaching the underlying promise.
  • Refuses to settle a promise with a promise that depends on it, preventing resolve cycles that would freeze a Swoole worker.

Integration

  • Adds ScopedTaskQueueInterface::track() for queues to cancel pending work when a scope ends; the default queue and Utils::queue() remain unchanged.
  • Requires PHP 8.4+ and Swoole, and replaces guzzlehttp/promises at the matching version without changing existing public contracts.

Maintenance

  • Documents the fork, its requirements, and coroutine ownership in the README and upgrade guide, including migration steps.
  • Points security reporting Hypervel.
  • Keeps CHANGELOG.md as upstream's history and records fork changes in release notes.

Summary by CodeRabbit

Summary

  • New Features
    • Pending promises can now only be operated on by their creating coroutine. Cross-coroutine operations on unfinished promises raise a LogicException; settled promises remain shareable.
    • Added support for scoped task queues that track pending work and cancel it when a scope ends.
  • Documentation
    • Updated installation and migration guidance for the Hypervel fork, including its PHP 8.4+ and Swoole requirements and coroutine ownership rules.