NeoGraph 0.16.0
A C++20 Graph Agent Engine Library — LangGraph for C++
Loading...
Searching...
No Matches
run_sync.h
Go to the documentation of this file.
1
24#pragma once
25
27
28#include <asio/any_io_executor.hpp>
29#include <asio/awaitable.hpp>
30#include <asio/bind_cancellation_slot.hpp>
31#include <asio/cancellation_signal.hpp>
32#include <asio/co_spawn.hpp>
33#include <asio/detached.hpp>
34#include <asio/error.hpp>
35#include <asio/io_context.hpp>
36#include <asio/post.hpp>
37#include <asio/query.hpp>
38#include <asio/system_error.hpp>
39#include <asio/thread_pool.hpp>
40#include <asio/traits/static_query.hpp>
41
42#include <atomic>
43#include <condition_variable>
44#include <cstddef>
45#include <exception>
46#include <mutex>
47#include <optional>
48#include <type_traits>
49#include <utility>
50
51namespace neograph::async {
52
53namespace detail {
54
61inline const asio::execution_context*& serial_io_context() noexcept {
62 static thread_local const asio::execution_context* context = nullptr;
63 return context;
64}
65
68inline bool on_serial_io_context(const asio::any_io_executor& executor) noexcept {
69 return &asio::query(executor, asio::execution::context) == serial_io_context();
70}
71
72class SerialIoContextScope {
73public:
74 explicit SerialIoContextScope(const asio::execution_context& context) noexcept
75 : previous_(serial_io_context()) {
76 serial_io_context() = &context;
77 }
78 ~SerialIoContextScope() { serial_io_context() = previous_; }
79 SerialIoContextScope(const SerialIoContextScope&) = delete;
80 SerialIoContextScope& operator=(const SerialIoContextScope&) = delete;
81
82private:
83 const asio::execution_context* previous_;
84};
85
86// A queued completion can run before the foreign thread's execute() returns:
87// io_context's scheduler unlocks its queue before signalling its wake event.
88// Keep those calls (and the final tracked-work release) alive through their
89// entire scheduler tail, not merely until their handler has completed.
90class SyncIoQuiescence {
91public:
92 explicit SyncIoQuiescence(const asio::execution_context& context) noexcept
93 : context_(&context) {}
94
95 ~SyncIoQuiescence() { wait(); }
96
97 void wait() noexcept {
98 // The caller-thread-only path needs no locking or reference counting.
99 if (!foreign_used_.load(std::memory_order_acquire)) return;
100 std::unique_lock lock(mutex_);
101 finished_.wait(lock, [this] { return active_ == 0; });
102 }
103
104 class Call {
105 public:
106 explicit Call(SyncIoQuiescence* owner) noexcept
107 : owner_(owner && serial_io_context() != owner->context_ ? owner : nullptr) {
108 if (owner_) {
109 std::lock_guard lock(owner_->mutex_);
110 ++owner_->active_;
111 owner_->foreign_used_.store(true, std::memory_order_release);
112 }
113 }
114
115 ~Call() {
116 if (owner_) {
117 std::lock_guard lock(owner_->mutex_);
118 if (--owner_->active_ == 0) {
119 // Notify under the lock: wait() must also outlive this
120 // notification, rather than racing its own event teardown.
121 owner_->finished_.notify_all();
122 }
123 }
124 }
125
126 Call(const Call&) = delete;
127 Call& operator=(const Call&) = delete;
128
129 private:
130 SyncIoQuiescence* owner_;
131 };
132
133private:
134 const asio::execution_context* context_;
135 std::atomic<bool> foreign_used_{false};
136 std::mutex mutex_;
137 std::condition_variable finished_;
138 std::size_t active_ = 0;
139};
140
141// Only used for a synchronous bridge's private io_context. Property adaptations
142// retain the fence, including through any_io_executor and cancellation strands.
143// The concrete io_context executor plus this borrowed pointer fit inline in
144// any_io_executor; the bridge owns the fence until all admitted calls return.
145template <typename Executor>
146class SyncIoExecutor {
147public:
148 SyncIoExecutor(Executor executor, SyncIoQuiescence& owner) noexcept
149 : executor_(std::move(executor)), owner_(&owner) {}
150
151 SyncIoExecutor(const SyncIoExecutor&) noexcept = default;
152 SyncIoExecutor(SyncIoExecutor&& other) noexcept
153 : executor_(std::move(other.executor_)),
154 owner_(std::exchange(other.owner_, nullptr)) {}
155
156 ~SyncIoExecutor() {
157 if constexpr (asio::traits::static_query<
158 Executor, asio::execution::outstanding_work_t>::value() ==
159 asio::execution::outstanding_work.tracked) {
160 SyncIoQuiescence::Call call(owner_);
161 // Destroy the tracked executor under the fence. Its work_finished()
162 // can let io.run() return before its scheduler stop/wake tail ends.
163 auto released = std::move(executor_);
164 static_cast<void>(released);
165 }
166 }
167
168 SyncIoExecutor& operator=(SyncIoExecutor other) noexcept {
169 using std::swap;
170 swap(executor_, other.executor_);
171 swap(owner_, other.owner_);
172 return *this;
173 }
174
175 template <typename Function>
176 void execute(Function&& function) const {
177 SyncIoQuiescence::Call call(owner_);
178 executor_.execute(std::forward<Function>(function));
179 }
180
181 template <typename Property>
182 auto query(const Property& property) const
183 noexcept(noexcept(asio::query(executor_, property)))
184 -> decltype(asio::query(std::declval<const Executor&>(), property)) {
185 return asio::query(executor_, property);
186 }
187
188 template <typename Property>
189 requires asio::can_require<const Executor&, const Property&>::value
190 auto require(const Property& property) const
191 -> SyncIoExecutor<std::decay_t<asio::require_result_t<const Executor&, const Property&>>> {
192 return {asio::require(executor_, property), *owner_};
193 }
194
195 template <typename Property>
196 requires asio::can_prefer<const Executor&, const Property&>::value
197 auto prefer(const Property& property) const
198 -> SyncIoExecutor<std::decay_t<asio::prefer_result_t<const Executor&, const Property&>>> {
199 return {asio::prefer(executor_, property), *owner_};
200 }
201
202 friend bool operator==(const SyncIoExecutor& left, const SyncIoExecutor& right) noexcept {
203 return left.owner_ == right.owner_ && left.executor_ == right.executor_;
204 }
205
206 friend bool operator!=(const SyncIoExecutor& left, const SyncIoExecutor& right) noexcept {
207 return !(left == right);
208 }
209
210private:
211 Executor executor_;
212 SyncIoQuiescence* owner_;
213};
214
215// Declare after the coroutine's captured state. Even an initiation failure
216// destroys the actual context while its borrowed fence, TLS scope, cancellation
217// signal and caller-owned coroutine captures are still alive.
218class SyncIoRun {
219public:
220 explicit SyncIoRun(std::exception_ptr& operation_error)
221 : io_(std::in_place), quiescence_(*io_), serial_scope_(*io_),
222 operation_error_(operation_error) {}
223
224 ~SyncIoRun() {
225 if (io_) {
226 quiescence_.wait();
227 cleanup_signal_.slot().clear();
228 io_.reset();
229 }
230 }
231
232 auto executor() noexcept {
233 return SyncIoExecutor(io_->get_executor(), quiescence_);
234 }
235
236 asio::cancellation_slot cleanup_slot() noexcept {
237 return cleanup_signal_.slot();
238 }
239
240 template <typename Executor>
241 void spawn(const Executor& executor, asio::awaitable<void> body,
242 asio::cancellation_slot slot) {
243 started_ = true;
244 try {
245 asio::co_spawn(executor, std::move(body),
246 asio::bind_cancellation_slot(slot,
247 [this](std::exception_ptr error) {
248 completed_ = true;
249 if (error && !operation_error_) {
250 operation_error_ = std::move(error);
251 }
252 }));
253 } catch (...) {
254 started_ = false;
255 remember(std::current_exception());
256 }
257 }
258
259 template <typename Cancel>
260 std::size_t drain(Cancel&& cancel) {
261 for (;;) {
262 std::size_t handled = 0;
263 try {
264 handled = io_->run();
265 } catch (...) {
266 // A side handler can throw while the owned coroutine is still
267 // suspended. Preserve that failure, request cancellation, and
268 // finish the frame before its slot binding/captures disappear.
269 remember(std::current_exception());
270 try {
271 cancel();
272 } catch (...) {
273 // An ill-behaved cancellation callback must not make us
274 // abandon the still-owned frame. Drain its real completion
275 // and preserve the first escaping failure.
276 remember(std::current_exception());
277 }
278 // A throwing handler may also have stopped the context.
279 // Restart before resuming drainage of its remaining work.
280 io_->restart();
281 continue;
282 }
283 if (!started_ || completed_) return handled;
284 // A handler may explicitly stop the private context. That is not
285 // completion of the operation owned by this blocking bridge.
286 io_->restart();
287 }
288 }
289
290 std::size_t drain() {
291 return drain([this] { cleanup_signal_.emit(asio::cancellation_type::all); });
292 }
293
294 // Call only after every token lease has closed cancellation admission.
295 void finish() {
296 cleanup_signal_.slot().clear();
297 quiescence_.wait();
298 // Posts admitted after the first run exhausted are queued on a stopped
299 // context. Invalidated token emits still own tracked strand invokers:
300 // drain them before destroying the context or its borrowed fence.
301 // Owned completion is not queue exhaustion. A finite caller-thread
302 // cleanup handler may stop the context with more notifications queued;
303 // each nonempty pass must be followed by a restarted run. A zero pass
304 // waits for legitimate outstanding work, including foreign tracked
305 // executor releases. Out-of-band foreign context.stop() is not owned
306 // by this caller-thread bridge, just like other raw context escapes.
307 do {
308 io_->restart();
309 } while (drain() != 0);
310 quiescence_.wait();
311 io_.reset();
312 if (error_) std::rethrow_exception(error_);
313 }
314
315private:
316 void remember(std::exception_ptr error) noexcept {
317 if (error && !error_) error_ = std::move(error);
318 }
319
320 std::optional<asio::io_context> io_;
321 SyncIoQuiescence quiescence_;
322 SerialIoContextScope serial_scope_;
323 asio::cancellation_signal cleanup_signal_;
324 std::exception_ptr error_;
325 std::exception_ptr& operation_error_;
326 bool started_ = false;
327 bool completed_ = false;
328};
329
330// Drive an engine operation with two cancellation scopes. The operation token
331// is exposed through RunContext to node/provider code; the private execution
332// child is reserved for the wrapper's co_spawn. Asio cancellation signals have
333// one mutable slot, so binding both layers to the same token lets a nested
334// consumer replace the wrapper handler and can leave its awaitable frame
335// clearing freed slot state during teardown.
336template <typename T>
337T run_sync_operation(
338 asio::awaitable<T> aw,
339 std::shared_ptr<neograph::graph::CancelToken> operation) {
340 if (operation) {
341 operation->throw_if_cancelled("run_sync operation entry");
342 }
343
344 std::optional<T> result;
345 std::exception_ptr err;
346
347 auto body = [&](std::shared_ptr<neograph::graph::CancelToken> execution)
348 -> asio::awaitable<void> {
349 try {
350 if (execution) execution->throw_if_cancelled("run_sync operation execution entry");
351 result.emplace(co_await std::move(aw));
352 } catch (...) {
353 err = std::current_exception();
354 }
355 co_return;
356 };
357 SyncIoRun run(err);
358 const auto executor = run.executor();
359
360 if (operation) {
361 // Keep the token visible to the operation's direct consumers while
362 // reserving a distinct signal for the wrapper's awaitable frame.
363 neograph::graph::CancelExecutorLease operation_lease(operation);
364 const auto operation_executor = operation->bind_executor(executor);
365 auto execution = operation->fork();
366 neograph::graph::CancelExecutorLease execution_lease(execution);
367 const auto execution_executor = execution->bind_executor(operation_executor);
368 asio::post(execution_executor, [execution_executor, execution, &body, &run] {
369 run.spawn(execution_executor, body(execution), execution->slot());
370 });
371 run.drain([&execution] { execution->cancel(); });
372 } else {
373 run.spawn(executor, body(std::shared_ptr<neograph::graph::CancelToken>{}),
374 run.cleanup_slot());
375 run.drain();
376 }
377 run.finish();
378
379 if (err) {
380 if (operation && operation->is_cancelled()) {
381 try {
382 std::rethrow_exception(err);
383 } catch (const asio::system_error& error) {
384 if (error.code() == asio::error::operation_aborted) {
386 "run_sync operation aborted");
387 }
388 } catch (...) {
389 }
390 }
391 std::rethrow_exception(err);
392 }
393 if (!result && operation && operation->is_cancelled()) {
395 "run_sync operation aborted before entry");
396 }
397 return std::move(*result);
398}
399
400} // namespace detail
401
420template <typename T>
421T run_sync(asio::awaitable<T> aw,
422 neograph::graph::CancelToken* cancel = nullptr) {
423 // v0.3.2: short-circuit if the parent token is already cancelled.
424 // Without this, the retry loop in NodeExecutor would re-call
425 // a provider dispatch after a first cancel, fresh run_sync would
426 // bind its slot AFTER add_cancel_hook fired its post-emit (the
427 // emit-before-bind race), the cancel signal would be lost, and
428 // the new HTTP request would run to completion — burning billable
429 // tokens on every retry attempt. Throwing CancelledException
430 // eagerly closes that loop: the executor's retry loop catches it
431 // (alongside NodeInterrupt) and skips retries, so a second
432 // cancelled call never hits the wire.
433 if (cancel && cancel->is_cancelled()) {
434 throw neograph::graph::CancelledException("run_sync entry");
435 }
436
437 std::optional<T> result;
438 std::exception_ptr err;
439
440 auto body = [&](std::shared_ptr<neograph::graph::CancelToken> child)
441 -> asio::awaitable<void> {
442 try {
443 if (child) child->throw_if_cancelled("run_sync execution entry");
444 result.emplace(co_await std::move(aw));
445 } catch (...) {
446 err = std::current_exception();
447 }
448 co_return;
449 };
450 detail::SyncIoRun run(err);
451 const auto executor = run.executor();
452
453 if (cancel) {
454 // v0.4 PR 3: fork a child token for this nested run_sync.
455 // The child has its own ``cancellation_signal``, bound to
456 // this io_context's executor; ``parent.cancel()`` cascades
457 // to every live child, so concurrent nested run_syncs
458 // (such as multi-Send fan-out workers bridging HTTP
459 // coroutines) all get their HTTP sockets torn down.
460 // The pre-v0.3.1 design bound the parent's single
461 // signal to io.get_executor() — last writer won, so only
462 // one of N concurrent workers received cancel and the rest
463 // streamed to completion. v0.3.1's ``add_cancel_hook`` list
464 // was a workaround on top of that single-signal model;
465 // ``fork()`` replaces it with a structural primitive.
466 //
467 // Lifetime: ``child`` is a ``shared_ptr`` so a late-firing
468 // cascade (parent racing with body completion) doesn't UAF.
469 // The parent stores a ``weak_ptr`` to the child, so once
470 // ``child`` goes out of scope at the end of this function
471 // the parent's children_ list naturally drops it.
472 //
473 // Eager-cancel race: if the parent was already cancelled
474 // before ``fork()``, the child's polling flag is pre-set;
475 // ``bind_executor`` then fires the child's signal at the
476 // first co_await checkpoint of body(). No "emit-vs-bind"
477 // window like the pre-v0.3.2 hook design, so the eager
478 // ``is_cancelled()`` short-circuit at function entry stays
479 // strictly as a tiny optimization (skip io_context
480 // construction altogether).
481 auto child = cancel->fork();
482 neograph::graph::CancelExecutorLease child_lease(child);
483 const auto child_executor = child->bind_executor(executor);
484 asio::post(child_executor, [child_executor, child, &body, &run] {
485 run.spawn(child_executor, body(child), child->slot());
486 });
487 run.drain([&child] { child->cancel(); });
488 // ``child`` goes out of scope at end of block → parent's
489 // weak_ptr expires → next parent.cancel()/fork() prunes it.
490
491 } else {
492 run.spawn(executor, body(std::shared_ptr<neograph::graph::CancelToken>{}),
493 run.cleanup_slot());
494 run.drain();
495 }
496 run.finish();
497
498 // Preserve the public bridge's existing typed cancellation translation,
499 // but make every error decision after admission closure and final drain.
500 if (err && cancel && cancel->is_cancelled()) {
501 throw neograph::graph::CancelledException("run_sync inner abort");
502 }
503
504 if (err) std::rethrow_exception(err);
505 return std::move(*result);
506}
507
509inline void run_sync(asio::awaitable<void> aw,
510 neograph::graph::CancelToken* cancel = nullptr) {
511 // v0.3.2: same eager short-circuit as the templated peer above.
512 // See that overload's comment for the retry/cost-leak rationale.
513 if (cancel && cancel->is_cancelled()) {
514 throw neograph::graph::CancelledException("run_sync entry");
515 }
516
517 std::exception_ptr err;
518
519 auto body = [&](std::shared_ptr<neograph::graph::CancelToken> child)
520 -> asio::awaitable<void> {
521 try {
522 if (child) child->throw_if_cancelled("run_sync execution entry");
523 co_await std::move(aw);
524 } catch (...) {
525 err = std::current_exception();
526 }
527 };
528 detail::SyncIoRun run(err);
529 const auto executor = run.executor();
530
531 if (cancel) {
532 // v0.4 PR 3: fork a child token. See the templated peer
533 // above for the full rationale; this is the bit-for-bit
534 // void specialization.
535 auto child = cancel->fork();
536 neograph::graph::CancelExecutorLease child_lease(child);
537 const auto child_executor = child->bind_executor(executor);
538 asio::post(child_executor, [child_executor, child, &body, &run] {
539 run.spawn(child_executor, body(child), child->slot());
540 });
541 run.drain([&child] { child->cancel(); });
542 } else {
543 run.spawn(executor, body(std::shared_ptr<neograph::graph::CancelToken>{}),
544 run.cleanup_slot());
545 run.drain();
546 }
547 run.finish();
548
549 if (err && cancel && cancel->is_cancelled()) {
550 throw neograph::graph::CancelledException("run_sync inner abort");
551 }
552
553 if (err) std::rethrow_exception(err);
554}
555
563template <typename T>
564T run_sync_pool(asio::awaitable<T> aw, std::size_t n_threads) {
565 asio::thread_pool pool(n_threads > 0 ? n_threads : 1);
566 std::optional<T> result;
567 std::exception_ptr err;
568
569 auto body = [&]() -> asio::awaitable<void> {
570 try {
571 result.emplace(co_await std::move(aw));
572 } catch (...) {
573 err = std::current_exception();
574 }
575 co_return;
576 };
577 asio::co_spawn(pool.get_executor(), body(), asio::detached);
578
579 pool.join();
580
581 if (err) std::rethrow_exception(err);
582 return std::move(*result);
583}
584
586inline void run_sync_pool(asio::awaitable<void> aw, std::size_t n_threads) {
587 asio::thread_pool pool(n_threads > 0 ? n_threads : 1);
588 std::exception_ptr err;
589
590 auto body = [&]() -> asio::awaitable<void> {
591 try {
592 co_await std::move(aw);
593 } catch (...) {
594 err = std::current_exception();
595 }
596 };
597 asio::co_spawn(pool.get_executor(), body(), asio::detached);
598
599 pool.join();
600
601 if (err) std::rethrow_exception(err);
602}
603
604} // namespace neograph::async
Cooperative cancellation primitive for graph runs.
Cooperative cancel primitive shared between caller and engine.
Definition cancel.h:82
Thrown by the engine when a run is cancelled mid-flight via CancelToken::cancel().
Definition cancel.h:65
const asio::execution_context *& serial_io_context() noexcept
The io_context an engine run is currently driving on this thread through run_sync_operation,...
Definition run_sync.h:61
bool on_serial_io_context(const asio::any_io_executor &executor) noexcept
Whether executor runs on the private io_context of a blocking bridge on this thread (run_sync,...
Definition run_sync.h:68
T run_sync(asio::awaitable< T > aw, neograph::graph::CancelToken *cancel=nullptr)
Run aw to completion on a fresh single-threaded io_context and return its result.
Definition run_sync.h:421
T run_sync_pool(asio::awaitable< T > aw, std::size_t n_threads)
Run aw to completion on a fresh N-worker asio::thread_pool and return its result.
Definition run_sync.h:564