Skip to content

Detach a running task instead of destroying its frame - #60

Open
otamachan wants to merge 1 commit into
mainfrom
detach-running-task
Open

otamachan wants to merge 1 commit into
mainfrom
detach-running-task

Conversation

@otamachan

@otamachan otamachan commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

~Task() destroyed the coroutine frame even while it was suspended in an awaiter. Everything that awaiter had handed the handle to -- a stream's waiter_, an Event queue, a pending service response, a posted resumption, a TF pending request -- then pointed at freed memory. Each awaiter grew its own guard against that (is_done predicates, weak StopCb checks, the destructors from #56), and every new awaiter would need one more.

This changes the ownership rule instead: a running frame is never destroyed by its owner. ~Task() (and move-assignment over a running task) requests stop and lets go of the frame. The awaiter resumes it on the executor as it already does for cancel(), CancelledException unwinds the body, and FinalAwaiter frees the frame. With a live frame behind every handed-out handle, the per-awaiter destructors from #56 are no longer needed and are removed.

create_task now posts the start instead of resuming on the spot. A task therefore starts after whatever was scheduled before it (e.g. the unwinding of a task cancelled just before), and a task released before its start never runs. Since the body no longer runs inside create_task, a lambda passed to it must outlive the call: this PR includes the commit from #59, which moves the callable into the task's frame. Merge #59 first and this PR shrinks to one commit, or merge this and close #59.

Visible changes, documented in the guide:

  • A destroyed task's body unwinds on the next spin, not on the spot. Destructors of locals and catch (const CancelledException &) handlers run after the owner has moved on.
  • A task suspended on an awaitable that does not support cancellation (e.g. a shielded task) lives until that awaitable completes, instead of being freed under it.
  • create_task returns before the coroutine has run. task = ctx.create_task(...) evaluates the right-hand side first, so to have the old task unwind before the new one starts, call task.cancel() first.

Tests:

  • DestroyedTaskUnwindsOnTheExecutor, DestroyedTaskWithoutCancellableAwaiterOutlivesItsOwner, TaskDestroyedBeforeItStartsNeverRuns and ReplacedTaskUnwindsBeforeItsReplacementStarts pin down the new rules.
  • ChannelTest.SendThenWaiterDestroyed / CloseThenWaiterDestroyed, TfBufferTest.TransformThenWaiterDestroyed and TaskDestroyTest.GoalResponseThenRequesterDestroyed destroy the task after its resumption was posted but before it ran. They crash on main (segfault in Debug, heap-use-after-free under ASan) and pass here with no awaiter-side change; they supersede Skip a posted resumption whose awaiter is gone #58.

Full suite passes in Debug (396 tests, lint included). Under ASan every test binary is clean.

~Task() destroyed the coroutine frame even while it was suspended in an
awaiter. Everything that awaiter had handed the handle to -- a stream's
waiter, an Event queue, a pending service response, a posted resumption,
a TF pending request -- then pointed at freed memory, and each awaiter
grew its own guard against that (is_done predicates, weak StopCb
checks, the destructors from #56).

A running frame is now never destroyed by its owner. ~Task() cancels it
and lets go: the awaiter resumes it on the executor, CancelledException
unwinds the body, and FinalAwaiter frees the frame. With a live frame
behind every handed out handle, the per-awaiter destructors are no
longer needed and are removed.

create_task now posts the start instead of resuming on the spot, so a
task starts after whatever was scheduled before it -- the unwinding of
a task cancelled just before, say -- and a task released before its
start never runs. This relies on the callable passed to create_task
being kept alive (previous commit).

Add tests for the ownership rule, the start order, and the remaining
cases where a resumption was posted before the task was destroyed
(Channel, TfBuffer, send_goal), which crashed on main.

This branch has not been deployed

No deployments
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.

1 participant