Skip to content

Resume a held handle only while its awaiter is alive - #64

Merged
otamachan merged 3 commits into
mainfrom
guard-held-handles
Oct 18, 2026
Merged

otamachan merged 3 commits into
mainfrom
guard-held-handles

Conversation

@otamachan

Copy link
Copy Markdown
Owner

Stacked on #63 (uses its Waiting).

#63 covered resumptions that are posted to the executor. Two awaiters instead hand a bare handle to something that outlives them, and resume it directly:

  • GoalStream::cancel_goal() gives [this, h] to the action client's cancel callback (co_context.hpp). CancelAwaiter is not cancellable and has no destructor, so destroying the waiting task before the cancel response arrives makes the callback write response into the freed awaiter and resume the freed frame.
  • co_await task stores the awaiting handle as the task's continuation. That is fine for co_await child(), where the child lives in the awaiting frame. When the awaited Task is owned elsewhere (e.g. a member, or task.cancel(); co_await task;) and the awaiting task is destroyed first, the awaited task resumes the freed frame from FinalAwaiter when it finishes.

Both now go through Waiting:

  • CancelAwaiter owns a Waiting, and the callback holds a weak_ptr to it. It writes the response and resumes only while that is alive.
  • Task gets an Awaiter (via operator co_await) that lives in the awaiting frame and owns the Waiting. promise_type::continuation becomes a weak_ptr<Waiting>, so it expires with the awaiting frame even when the Task outlives it. shield() owns its Waiting the same way. when_all / when_any keep their own continuation and are unchanged.

Tests (test_task_destroy):

  • CancelGoalResponseAfterRequesterDestroyed: the client answers in its own group; the task awaiting cancel_goal() is destroyed before that group spins.
  • AwaitedTaskFinishesAfterAwaiterDestroyed: a task awaiting another create_tasked task is destroyed while it waits.

Built with -fsanitize=address, both report a heap-use-after-free without this change and pass with it. colcon test passes (354 tests, ASan build, LeakSanitizer off).

Channel::send/close, TfBuffer's transform arrival and send_goal's goal
response post the waiting coroutine's resumption without checking that
it is still waiting. A task destroyed after the post but before the
executor runs it is then resumed from freed memory.

Add Waiting, owned by the awaiter, and post_resume, which resumes it
through a weak_ptr when the posted callback runs. A late resumption finds
the weak_ptr expired with the awaiter.

Add tests for the three cases, which hit a heap-use-after-free on main
under ASan, and one that destroys a node-like owner of the context and
its task while the task waits on an Event, a sleep or a topic.
Two awaiters hand a bare handle to something that outlives them:

- GoalStream::cancel_goal() gives `this` and the handle to the action
  client's cancel callback. Destroying the waiting task before the cancel
  response arrives makes the callback write to the freed awaiter and
  resume the freed frame.
- co_await on a Task stores the awaiting handle as the task's
  continuation. When the awaited Task is owned elsewhere and the awaiting
  task is destroyed first, the awaited task resumes the freed frame when
  it finishes.

Both now go through Waiting, as the posted resumptions do. Task gets an
Awaiter that lives in the awaiting frame and owns the Waiting, and the
continuation becomes a weak_ptr to it; shield() does the same.

Add a test for each, which hit a heap-use-after-free under ASan without
this change.
Base automatically changed from guard-posted-resume to main October 18, 2026 06:14
@otamachan
otamachan merged commit 1df0df0 into main Oct 18, 2026
6 checks passed
@otamachan
otamachan deleted the guard-held-handles branch October 18, 2026 06:15
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