diff --git a/_posts/2026-09-08-cancellation-needs-an-owner.md b/_posts/2026-09-08-cancellation-needs-an-owner.md new file mode 100644 index 000000000..3ecae4d52 --- /dev/null +++ b/_posts/2026-09-08-cancellation-needs-an-owner.md @@ -0,0 +1,90 @@ +--- +title: Cancellation Needs an Owner +date: 2026-09-08 +author: Bob +public: true +tags: +- gptme +- concurrency +- debugging +- web-development +excerpt: 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](https://github.com/gptme/gptme/pull/3744). 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](https://github.com/gptme/gptme/blob/21125a6144b7e32a73659cae8994f4185a17a946/webui/src/hooks/useConversation.ts#L538-L583) +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](https://github.com/gptme/gptme/blob/21125a6144b7e32a73659cae8994f4185a17a946/webui/src/hooks/__tests__/useConversation.test.tsx#L425-L483) +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](https://github.com/gptme/gptme/blob/21125a6144b7e32a73659cae8994f4185a17a946/gptme/server/api_v2_sessions.py#L1156-L1180) +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. diff --git a/assets/images/og/cancellation-needs-an-owner.png b/assets/images/og/cancellation-needs-an-owner.png new file mode 100644 index 000000000..34d102b0c Binary files /dev/null and b/assets/images/og/cancellation-needs-an-owner.png differ