NeoGraph 0.16.0
A C++20 Graph Agent Engine Library — LangGraph for C++
Loading...
Searching...
No Matches
cancel.h
Go to the documentation of this file.
1
33#pragma once
34
35#include <neograph/api.h>
36
37#include <asio/any_io_executor.hpp>
38#include <asio/cancellation_signal.hpp>
39#include <asio/post.hpp>
40#include <asio/strand.hpp>
41
42#include <algorithm>
43#include <condition_variable>
44#include <stop_token>
45#include <cstdint>
46#include <functional>
47#include <memory>
48#include <mutex>
49#include <stdexcept>
50#include <utility>
51#include <vector>
52
53namespace neograph::graph {
54
65class NEOGRAPH_API CancelledException : public std::runtime_error {
66public:
68 : std::runtime_error("neograph: run cancelled") {}
69 explicit CancelledException(const std::string& detail)
70 : std::runtime_error("neograph: run cancelled — " + detail) {}
71};
72
82class NEOGRAPH_API CancelToken {
83public:
84 CancelToken() = default;
85
86 CancelToken(const CancelToken&) = delete;
87 CancelToken& operator=(const CancelToken&) = delete;
88
104 void cancel() noexcept {
105 if (stop_source_.stop_requested()) return;
106 // Retain operation children before a native callback can release its
107 // awaiting frame. No callback runs while either token mutex is held.
108 auto keep_alive = self_keep_alive_for_post();
109 if (!stop_source_.request_stop()) return;
110
111 // Serialize executor submission with unbind_executor(), so completed
112 // operations cannot post into a context that has begun teardown.
113 {
114 std::lock_guard<std::mutex> lk(mu_);
115 if (ex_) {
116 asio::post(ex_,
117 [this, binding = binding_generation_,
118 keep_alive = std::move(keep_alive)]() {
119 emit_if_bound(binding);
120 });
121 }
122 }
123
124 // v1.0 (9c): the legacy ``add_cancel_hook`` / hooks_ iteration
125 // is gone — ``fork()`` is the canonical primitive for nested
126 // cancel scopes, and every internal ``run_sync`` cascades via
127 // a forked child.
128
129 // Cascade to live children produced by ``fork()``.
130 // Snapshot live shared_ptrs under the lock, then call
131 // ``cancel()`` on each outside the lock — the recursive call
132 // would otherwise re-enter ``children_mu_`` if a grandchild
133 // exists, and lock_guard isn't reentrant.
134 std::vector<std::shared_ptr<CancelToken>> live_children;
135 {
136 std::lock_guard<std::mutex> lk(children_mu_);
137 live_children.reserve(children_.size());
138 for (auto& w : children_) {
139 if (auto sp = w.lock(); sp && sp.get() != this) {
140 live_children.push_back(std::move(sp));
141 }
142 }
143 // Don't bother pruning expired entries here — fork() does
144 // it on the next call, and after this cancel() the parent
145 // is "done" anyway.
146 }
147 for (auto& child : live_children) {
148 child->cancel();
149 }
150 }
151
153 [[nodiscard]] bool is_cancelled() const noexcept {
154 return stop_source_.stop_requested();
155 }
156
158 [[nodiscard]] std::stop_token stop_token() const noexcept {
159 return stop_source_.get_token();
160 }
161
181 [[nodiscard]] asio::any_io_executor bind_executor(asio::any_io_executor ex) {
182 asio::any_io_executor bound;
183 bool fire_immediately = false;
184 std::uint64_t binding = 0;
185 {
186 std::lock_guard<std::mutex> lk(mu_);
187 ex_ = ex ? asio::any_io_executor(asio::make_strand(std::move(ex)))
188 : asio::any_io_executor{};
189 binding = ++binding_generation_;
190 bound = ex_;
191 fire_immediately = stop_source_.stop_requested();
192 }
193 emit_finished_.notify_all();
194 if (fire_immediately && bound) {
195 auto keep_alive = self_keep_alive_for_post();
196 asio::post(bound,
197 [this, binding, keep_alive = std::move(keep_alive)]() {
198 emit_if_bound(binding);
199 });
200 }
201 return bound;
202 }
203
227 void unbind_executor() noexcept {
228 asio::any_io_executor released;
229 {
230 std::unique_lock<std::mutex> lk(mu_);
231 released = std::move(ex_);
232 ++binding_generation_;
233 emit_finished_.wait(lk, [this] { return !emitting_; });
234 }
235 // No emit can now touch the signal. Clear outside mu_: destruction of
236 // a user's completed handler may itself request cancellation.
237 sig_.slot().clear();
238 // Keep the former executor alive through this method's end, while its
239 // execution context is still known to be alive to the caller.
240 static_cast<void>(released);
241 }
242
252 asio::cancellation_slot slot() noexcept {
253 return sig_.slot();
254 }
255
260 void throw_if_cancelled(const std::string& detail = {}) const {
261 if (is_cancelled()) {
262 throw CancelledException(detail);
263 }
264 }
265
298 [[nodiscard]] std::shared_ptr<CancelToken> fork() {
299 // Construct child outside any lock — its ctor takes no lock.
300 // shared_ptr ctor allocates the control block; cheap relative
301 // to a real cancel scope (one io_context spin-up downstream).
302 auto child = std::shared_ptr<CancelToken>(new CancelToken());
303
304 // The weak self-entry retains operation children through posted emits.
305 // cancel() excludes this entry from parent-to-child propagation.
306 {
307 std::lock_guard<std::mutex> lk(child->children_mu_);
308 child->children_.push_back(child);
309 }
310
311 {
312 std::lock_guard<std::mutex> lk(children_mu_);
313 // Opportunistic prune: every fork() is a natural place to
314 // drop expired weak_ptrs from previous children that have
315 // already been released by their owners. Bounds the
316 // children_ vector at the live-fan-out width even on
317 // long-lived parents (e.g. an engine with 1000 LLM calls
318 // per run, each forking once).
319 children_.erase(
320 std::remove_if(
321 children_.begin(), children_.end(),
322 [](const std::weak_ptr<CancelToken>& w) {
323 return w.expired();
324 }),
325 children_.end());
326 children_.push_back(child);
327 }
328
329 // Eager propagation: parent already cancelled → child sees
330 // the polling flag immediately. ``bind_executor`` on the
331 // child will then fire its signal at the next co_await.
332 if (stop_source_.stop_requested()) {
333 child->cancel();
334 }
335 return child;
336 }
337
338private:
339 void emit_if_bound(std::uint64_t binding) {
340 {
341 std::unique_lock<std::mutex> lk(mu_);
342 emit_finished_.wait(lk, [this, binding] {
343 return !ex_ || binding != binding_generation_ || !emitting_;
344 });
345 if (!ex_ || binding != binding_generation_) return;
346 emitting_ = true;
347 }
348 struct EmitCompletion {
349 CancelToken& token;
350 ~EmitCompletion() {
351 {
352 std::lock_guard<std::mutex> lk(token.mu_);
353 token.emitting_ = false;
354 }
355 token.emit_finished_.notify_all();
356 }
357 } complete{*this};
358 // Signal handlers may reenter bind_executor() or cancel(). Admission
359 // is fenced above, but no token mutex may be held through a callback.
360 sig_.emit(asio::cancellation_type::all);
361 }
362
363 std::shared_ptr<CancelToken> self_keep_alive_for_post() {
364 std::lock_guard<std::mutex> lk(children_mu_);
365 for (auto& candidate : children_) {
366 if (auto self = candidate.lock(); self.get() == this) {
367 return self;
368 }
369 }
370 return {};
371 }
372
373 std::stop_source stop_source_;
374 mutable std::mutex mu_; // guards executor and emit admission
375 std::condition_variable emit_finished_;
376 std::uint64_t binding_generation_ = 0;
377 bool emitting_ = false;
378 asio::any_io_executor ex_; // bound by engine before HTTP I/O
379 asio::cancellation_signal sig_; // for asio operation cancel
380
381 // Hierarchical cascade list. Forked children retain a weak self-entry for
382 // cancellation callbacks and posted emits; cancel() skips it when cascading.
383 mutable std::mutex children_mu_;
384 std::vector<std::weak_ptr<CancelToken>> children_;
385};
386
387
388// Keeps a token's executor binding scoped to the operation that drained it.
389// Construct after the token is bound; destruction must occur before the
390// corresponding io_context or thread_pool is torn down.
391class CancelExecutorLease final {
392public:
393 explicit CancelExecutorLease(std::shared_ptr<CancelToken> token) noexcept
394 : token_(std::move(token)) {}
395
396 CancelExecutorLease(const CancelExecutorLease&) = delete;
397 CancelExecutorLease& operator=(const CancelExecutorLease&) = delete;
398
399 ~CancelExecutorLease() noexcept {
400 if (token_) token_->unbind_executor();
401 }
402
403private:
404 std::shared_ptr<CancelToken> token_;
405};
406
407
408// v1.0 (9d): the `current_cancel_token()` thread_local +
409// `CurrentCancelTokenScope` RAII smuggling channel is gone.
410// `RunContext::cancel_token` (on `NodeInput::ctx`) is the only cancel
411// channel. Engine threads it through every dispatch.
412
413} // namespace neograph::graph
NEOGRAPH_API export/import macro for shared-library builds.
Cooperative cancel primitive shared between caller and engine.
Definition cancel.h:82
void unbind_executor() noexcept
Detach the serial executor after every bound operation drained.
Definition cancel.h:227
std::stop_token stop_token() const noexcept
Native cancellation subscriptions independent of Asio slot ownership.
Definition cancel.h:158
void throw_if_cancelled(const std::string &detail={}) const
Throws CancelledException if cancelled.
Definition cancel.h:260
asio::any_io_executor bind_executor(asio::any_io_executor ex)
Bind a serial executor that owns this token's cancellation_signal.
Definition cancel.h:181
asio::cancellation_slot slot() noexcept
Cancellation slot for binding to a coroutine via asio::bind_cancellation_slot at co_spawn.
Definition cancel.h:252
void cancel() noexcept
Request cancellation.
Definition cancel.h:104
bool is_cancelled() const noexcept
Thread-safe observation of the same state used by subscriptions.
Definition cancel.h:153
std::shared_ptr< CancelToken > fork()
Create a child token that cascades from this one.
Definition cancel.h:298
Thrown by the engine when a run is cancelled mid-flight via CancelToken::cancel().
Definition cancel.h:65