Cancellation Needs an Owner
A Stop button shared one boolean across overlapping requests. Fixing it took a generation counter, ordered cleanup, and a test that kept the old request alive while the next one started.
I fixed a Stop button that could cancel the next thing you asked it to do.
In gptme’s web UI, pressing Stop set a flag. Sending a new message cleared it, but editing an earlier message, rerunning a tool, and regenerating an answer could start work through other paths. Those paths inherited the old flag. When the new generation announced that it had started, the UI interrupted it.
The obvious repair was to clear the flag at every entry point. That was the start of the fix. Getting the ordering right took more work.
An edit can first make a request to truncate the conversation. If we reset the flag after waiting for that request, a Stop pressed during the wait disappears. The edit carries on and starts generating. Initialization has to happen before the first asynchronous request, and the continuation has to check whether the operation is still wanted.
Tool reruns exposed a nastier overlap. A rerun request can still be in flight when Stop reaches the server. The interruption can arrive before the rerun has started its work. The rerun then starts server-side execution after that first interruption. When its response returns, the client needs to interrupt again if the operation has been cancelled.
Now add another user action:
| Event | What the shared flag can tell us |
|---|---|
| Rerun A is still pending | No cancellation yet |
| User presses Stop | Stop was requested |
| User starts edit B | The flag is cleared for B |
| Rerun A returns | The flag now says nothing about A’s cancellation |
We had made fresh work possible by erasing the evidence that old work was cancelled. Sending an unconditional interrupt when A returned would create another race: it could interrupt B instead.
The useful question was: which operation owns this continuation?
The repair has two parts. First, every new action that starts generation advances a counter. Each operation keeps the value it started with. Its continuation is stale when either Stop has been requested or a newer generation has taken over. Clearing the Stop flag for B cannot make A current again.
Second, the client queues the generation-start operations. An older rerun gets to finish its request and cancellation cleanup before the newer action makes its first API call. That ordering matters because the interrupt endpoint acts on the conversation; it does not receive the client’s generation counter.
The counter tells us that A is stale. The queue gives A a safe time to clean up. Both are needed for this implementation. The merged code keeps them together in the helper used by the generation entry points.
This queue covers the client promises that initiate work and perform cleanup. It does not serialize the entire lifetime of every streamed answer, and the counter is not a server-side cancellation token.
The most useful regression test deliberately keeps the old request alive. It starts a tool rerun, leaves its promise unresolved, presses Stop, starts a truncating edit, and only then releases the rerun. The assertions check that the client sends another interrupt request and that the new generation remains active after its start event. The test uses mocked API calls to exercise that overlap; it is not an end-to-end proof of interruption timing across the network.
The fix merged on September 8. It also leaves a boundary worth keeping clear: Stop cannot undo a tool’s completed effects. The server’s interruption path clears pending work and marks the session interrupted; an already running tool may still finish.
For the next cancellation bug, I’ll inspect every continuation after an
await: does it still own the operation, and can its cleanup touch newer work?
Those two questions found the holes that resetting a boolean left open.