graphrefly_core/owned.rs
1//! [`OwnedCore`] — the one canonical Core-ownership keystone (D246 rule 4).
2//!
3//! The actor-model [`Core`] is move-only and single-owner (D221/D223).
4//! Every embedder — the napi/pyo3/wasm bindings, every test harness, an
5//! application embedding GraphReFly — needs the *same* three things:
6//!
7//! 1. **own** the relocatable `Core` by value,
8//! 2. **track** the subscriptions it opens, and
9//! 3. **tear them down on the owner thread** when it goes away.
10//!
11//! Before D246 this keystone was triplicated as `TestRuntime` /
12//! `StructuresRuntime` / the operators harness runtime (plus the napi
13//! `CURRENT_CORE` hack). `OwnedCore` is the single abstraction they all
14//! compose. RAII teardown lives **here, at the embedder boundary** —
15//! never below it (D246 rule 3: `subscribe → id` + `core.unsubscribe`;
16//! no parameterless `Drop` reaching Core anywhere in the substrate). The
17//! `Drop` here is sound because `OwnedCore` *owns* the `Core` by value:
18//! it is on the dropping thread's stack, alive, no relocation (this is
19//! exactly the D225 owner-invoked-synchronous-unsubscribe shape, not the
20//! retired core RAII).
21//!
22//! Producer build/teardown (D231/D245) is just "the owner calls
23//! `owned.core()` synchronously" — `OwnedCore` *is* the canonical
24//! producer-build site; no per-binding "how do I reach Core" remains.
25//!
26//! D246/S2c: single-owner ⇒ the sub-tracking vecs are plain `RefCell`
27//! (the prior `Mutex` was shared-Core-era legacy — an `OwnedCore` is
28//! touched by exactly one thread, the one that owns the `Core`). A
29//! re-entrant double-borrow on the cold teardown path panics loudly
30//! rather than corrupting state.
31
32use std::cell::RefCell;
33use std::sync::Arc;
34
35use crate::boundary::BindingBoundary;
36use crate::handle::NodeId;
37use crate::node::{Core, Sink, SubscriptionId};
38use crate::topology::{TopologySink, TopologySubscriptionId};
39
40/// Owns a [`Core`], tracks the subscriptions opened through it, and
41/// tears them down on the owner thread in `Drop`. Construct with
42/// [`OwnedCore::new`] (fresh `Core`) or [`OwnedCore::with_core`] (adopt
43/// a `Core` the caller already built).
44pub struct OwnedCore {
45 core: Core,
46 subs: RefCell<Vec<(NodeId, SubscriptionId)>>,
47 topo_subs: RefCell<Vec<TopologySubscriptionId>>,
48}
49
50impl OwnedCore {
51 /// Build a fresh `Core` wired to `binding` and own it.
52 #[must_use]
53 pub fn new(binding: Arc<dyn BindingBoundary>) -> Self {
54 Self::with_core(Core::new(binding))
55 }
56
57 /// Adopt a `Core` the caller already constructed.
58 #[must_use]
59 pub fn with_core(core: Core) -> Self {
60 Self {
61 core,
62 subs: RefCell::new(Vec::new()),
63 topo_subs: RefCell::new(Vec::new()),
64 }
65 }
66
67 /// Borrow the owned dispatcher. This is the D231 owner-side `&Core`
68 /// — pass it explicitly into every Core-touching op (graph,
69 /// structures, storage, producer build). Never store or clone it.
70 #[must_use]
71 #[inline]
72 pub fn core(&self) -> &Core {
73 &self.core
74 }
75
76 /// The binding this `Core` was wired with.
77 #[must_use]
78 #[inline]
79 pub fn binding(&self) -> Arc<dyn BindingBoundary> {
80 self.core.binding()
81 }
82
83 /// Subscribe a sink and track it for owner-thread teardown on drop.
84 /// Returns the [`SubscriptionId`] for an explicit early
85 /// [`Self::unsubscribe`] (D246 rule 3 — owner-invoked, synchronous;
86 /// no RAII below the binding).
87 pub fn track_subscribe(&self, node_id: NodeId, sink: Sink) -> SubscriptionId {
88 let sub_id = self.core.subscribe(node_id, sink);
89 self.subs.borrow_mut().push((node_id, sub_id));
90 sub_id
91 }
92
93 /// Subscribe a topology sink and track it for teardown on drop.
94 pub fn track_subscribe_topology(&self, sink: TopologySink) -> TopologySubscriptionId {
95 let id = self.core.subscribe_topology(sink);
96 self.topo_subs.borrow_mut().push(id);
97 id
98 }
99
100 /// Explicit early unsubscribe (owner-invoked, synchronous — D225/
101 /// D241). Idempotent: a later `Drop` won't double-unsubscribe a
102 /// detached id (and core unsubscribe is itself idempotent on
103 /// monotonic never-recycled ids).
104 pub fn unsubscribe(&self, node_id: NodeId, sub_id: SubscriptionId) {
105 self.subs
106 .borrow_mut()
107 .retain(|&(n, s)| !(n == node_id && s == sub_id));
108 self.core.unsubscribe(node_id, sub_id);
109 }
110
111 /// Explicit early topology unsubscribe (owner-invoked, synchronous).
112 pub fn unsubscribe_topology(&self, id: TopologySubscriptionId) {
113 self.topo_subs.borrow_mut().retain(|&i| i != id);
114 self.core.unsubscribe_topology(id);
115 }
116}
117
118impl Drop for OwnedCore {
119 fn drop(&mut self) {
120 // Owner-thread, `Core` owned-by-value-and-alive: synchronous
121 // unsubscribe is sound (D225 owner-invoked shape, NOT the
122 // retired relocating-Core RAII). Topology subs first so a
123 // topo fire mid-teardown can't re-enter a half-removed sink.
124 //
125 // QA-A2: pop-one-at-a-time (re-borrow per item, borrow released
126 // across `core.unsubscribe`) rather than take-then-iterate.
127 // `Core::unsubscribe` runs the lock-released deactivation
128 // chain (`OnDeactivation` cleanup, `producer_deactivate`,
129 // `wipe_ctx`) which can fire sinks synchronously and re-enter
130 // this same `OwnedCore` via `track_subscribe*`. A drained
131 // snapshot would silently drop any sub registered during
132 // teardown; the pop-loop observes those and tears them down
133 // too (the borrow is never held across `core.unsubscribe`, so
134 // no double-borrow panic). This is the single canonical
135 // teardown keystone — re-entrancy-safety matters more than the
136 // micro-cost of per-item re-borrowing on a cold drop path.
137 while let Some(id) = pop(&self.topo_subs) {
138 self.core.unsubscribe_topology(id);
139 }
140 while let Some((node_id, sub_id)) = pop(&self.subs) {
141 self.core.unsubscribe(node_id, sub_id);
142 }
143 }
144}
145
146#[inline]
147fn pop<T>(c: &RefCell<Vec<T>>) -> Option<T> {
148 c.borrow_mut().pop()
149}
150
151// QA-A4 (deleted D248): the `OwnedCore: Send + Sync` assertion is gone.
152// Under D246/S2c/D248 full single-owner the substrate `Sink` /
153// `TopologySink` dropped their `Send + Sync` bound (shared-Core-era
154// legacy), so `Core` — which owns the subscriber map — is `!Send +
155// !Sync`, and therefore so is `OwnedCore`. This is the actor-model
156// shape: an `OwnedCore` is constructed, driven, and dropped on exactly
157// one thread; the **only** cross-thread bridge is the `Arc<CoreMailbox>`
158// (id-only timer `Emit` posts), which stays `Send + Sync` independently
159// (asserted in `mailbox.rs`).