Part 1 argued the trade, a freedom given up for a guarantee. This part is the concrete case. It adds no capability. It removes a way to lose track of work you started, and with it the need to read the rest of the program before you can read one function.
What a leak is
malloc() without free() leaks because C has no way to say who is responsible for the block, not because the programmer was careless. The pointer is a handle. A handle says where something is, not whose job it is to end it.
Managed memory moved responsibility from the programmer into the runtime, as a reference count, a collector or a lifetime. Nobody promises to clean up any more, because nobody is asked to.
The same leak, with coroutines
A fiber is an allocation. It occupies memory, it holds everything it closed over, and somebody has to end it. Amp\async() is the malloc() of concurrency. It hands you a handle and a promise to clean up.
$future = Amp\async(fn () => $this->client->request($url));
The coroutine keeps running. Nothing in PHP connects it to the function that started it. Drop the Future and the work continues, holding whatever it closed over, such as the container, the request, an entity or a database connection.
Read the function that made that call. It returns. Whether the work is over, whether it failed, and who hears about it if it did, is not on the page. You cannot reason about this code locally. That is the loss, and everything below is about getting it back.
amphp says so in its API. Future::ignore() declares that an error from work nobody awaits is dropped rather than reported. The combinators await without owning. Future\await() returns on the first error while the siblings run on. The layer stops below ownership by design.
A Future is a pointer, then, and says as little about whose job it is to end the thing. It is also worse than a pointer. A leaked allocation is inert. A leaked fiber wakes up, writes, and throws.
Why you have not been bitten yet
Because PHP kills the process. One request per process is a garbage collector so blunt that PHP calls it shared-nothing and lists it as a feature. exit collects every leak, every dangling fiber and every half-finished coroutine. Correctness by termination.
FrankenPHP, Swoole and RoadRunner take that away. The process now runs for days. A coroutine that outlived its request is still there on the next one, holding a request object that should have been garbage, waiting on a socket nobody will answer. Its exception surfaces in a log line with no context, because the code that would have caught it returned an hour ago. I have no filed issue to cite for this one. The two in part 3 have the same shape and the same cause, caught earlier because state bleeds inside a single request. PHP's frameworks were written for a world where the process boundary did the cleanup. Persistent servers remove that boundary for performance. Something else now has to do what it did silently.
What a scope does
Four rules. Trio, Kotlin and Java's JEP all have them:
- A task starts only inside a scope.
- The scope does not exit until every child it started has finished.
- If a child lets an exception escape, the scope cancels its siblings.
- That exception surfaces where the scope was opened.
Concurrency gets one entry and one exit, like a block. You can no longer leak a coroutine by forgetting, just as you can no longer leak managed memory that way.
The payoff is the one from part 1. Read a function that opens a scope. When it returns, every task it started is over, and every error one of them raised has surfaced here or been handled here. You know that from the function alone. Nothing beside it, above it or after it has to be read first. That is what reasoning locally means, and the four rules exist for nothing else.
The rules also hold on the path nobody plans for. A scope is an object. When the last reference to it goes, its destructor cancels the children and waits for them. That happens when the variable leaves PHP scope, when somebody unsets it, or when the object holding it goes. Forgetting to end a scope is not a way to leak past it. The destructor cost more to build than the four rules did. Without it, a forgotten scope would break them. One hole remains. A destructor cannot throw, so it reports instead. If something outside cancels its own wait, it stops waiting, and what is left is cancelled rather than awaited.
This is a layer, not a framework. It sits on stock Revolt and stock amphp, on unpatched PHP 8.4. The event loop stays theirs, the I/O stays theirs, and the missing piece above them is ours.
Ownership is transferred, not dropped
"But my task has to outlive the request." It does, and it should. Ownership is not a synonym for short-lived.
A background job that must survive the response changes owner. The request hands it to the task that owns a scope whose lifetime is the application, and that task starts it. None of the four rules bends. The job has a scope, the scope has an owner, and the owner lives longer than the request.
Local reasoning survives the hand-over, on both sides. Read the request handler and you see the hand-over, and you know that nothing else it started outlives it. Read the application task and you see every long-running job the server carries, where their errors land, and what happens to them at shutdown. Fire-and-forget answers neither question anywhere.
The freedom you give up is the freedom to start work that nobody is responsible for. That is the trade from part 1, in its most literal form. This is the only exception to "a task ends with the function that started it", and it is no exception to the model. It is a longer owner.
Foreign work, until the trade is made
The rules hold for work the scope started. In a language and a framework that enforce structured concurrency, that is all the work there is, and this section does not exist. PHP is not there. Amp\async() is a free function, every amphp library calls it, and every ReactPHP library returns a promise nobody owns. Until language and framework enforce the trade, a layer above them has to work with what is there. It does, in two degrees below owned. Neither is the design. They are the adapter to a world that has not made the trade yet, and they are named here so that nobody mistakes them for the model.
Owned. The scope started it. Full teardown on exit, no cooperation required from the code inside. Reading the function tells you everything.
Attached. Someone else started it, but it accepts a cancellation signal from us. Examples are an HTTP client, a database query, or anything that checks throwIfRequested(). Teardown works as far as the callee checks, and in the amphp ecosystem most things do. Whether the token reaches them depends on the code in between, and in a framework that is the adapter's job. Reading the function tells you what you asked for, not whether the callee listened.
Unowned. A future made elsewhere that we merely await. Waiting ends; the work does not. Reading the function tells you nothing about the work at all.
Unowned is narrower than it first looked. With the event-loop driver under a scope, an Amp\async() started inside one of its tasks becomes that task's child and goes down with it. Unowned needs a future to cross in from outside, either handed over or a DeferredFuture that has no producing task at all. That is one identifiable shape, not a general hole.
When a scope tears down with an unowned wait still open, it reports a leak at the boundary where the scope was run. A clean exit cannot produce one. The scope awaited everything it owned, so every wait inside those tasks had already returned. The rule bites on the teardown, which is the only place it could. Staying quiet there would break the promise.
The aim is that this section shrinks. A library that spawns inside one of our tasks is owned already, without a changed line. A library that takes a cancellation token is attached. What stays unowned is the future handed in from outside, and that is a shape you can point at, in review or by a tool, before the code runs. Legacy and foreign code are why the two lower degrees exist. They are not a second way to write new code.
One limit sits under all three, and no library design removes it. Cancellation in userland is cooperative. A fiber blocked in a non-async call cannot be cancelled. The engine could force a coroutine to stop between opcodes, but that is not obviously better. The Scope RFC had to add Async\protect() so that code which must not be interrupted can say so. Cooperative cancellation has that property by construction. Userland gives you full ownership of everything it started and cooperative teardown of everything that accepts a signal. That is the whole claim.
Does it work
Yes.
A scope that exits while its children are still running cancels them and leaves no fiber behind. An exception in one child tears down its siblings and arrives where the scope was opened. The first error propagates; the others stay recorded on their tasks. A scope dropped instead of ended cancels and waits from its destructor, and reports instead of throwing. Each of those is a test, not an anecdote.
The repo is not public yet. Part 4 says why, and how to get in.
Next
Ownership fixes leaked work. It does not fix what that work sees. Two fibers run under one scope, one switches the current user, and the other one is now that user too. Read the second fiber and nothing on the page says so. That is the same loss, local reasoning, this time about state. It is part 3, and it is why Drupal currently switches concurrency off instead of fixing it.
Talk to me
I am at DrupalCon Rotterdam, 28 September to 1 October, and at the International PHP Conference in Munich on 27 and 28 October. Otherwise, write to merlin [at] hook-dev-alter.com (merlin[at]hook-dev-alter[dot]com). Tell me where your code starts work nobody waits for, and what it cost you when it leaked.
Disclaimer: my friend the babble-machine helped a lot with research, structure and wording. All errors are mine.