graphrefly_core/boundary.rs
1//! The FFI surface — the only path from Core to user code.
2//!
3//! Mirrors `BindingBoundary` in
4//! `~/src/graphrefly-ts/src/__experiments__/handle-core/core.ts:122–126`.
5//!
6//! # Boundary discipline (handle-protocol cleaving plane)
7//!
8//! The Core never sees user values `T`. When the dispatcher needs to invoke
9//! user code, run a custom equals oracle, or release a value-handle's
10//! refcount, it calls into [`BindingBoundary`]. The binding-side implementation
11//! resolves handles to values, runs the user code, and returns either a new
12//! handle or a no-op signal.
13//!
14//! In a Rust core compiled to a napi-rs / pyo3 / wasm-bindgen cdylib, the
15//! `impl BindingBoundary` lives in the bindings crate; it owns the
16//! value registry (`HashMap<HandleId, T>` plus a value→handle dedup map).
17//!
18//! Per the rust-port session doc Part 2: this trait is the *only* mandatory
19//! FFI crossing per fn-fire. Internal protocol bookkeeping (DIRTY propagation,
20//! batch coalescing, equals-substitution under identity, version counters,
21//! PAUSE/RESUME, INVALIDATE, first-run gate) stays Core-internal — zero FFI.
22
23use smallvec::SmallVec;
24
25use crate::handle::{FnId, HandleId, NodeId, NO_HANDLE};
26use crate::node::SubscriptionId;
27
28/// Per-dep batch data passed to [`BindingBoundary::invoke_fn`].
29///
30/// Mirrors the canonical spec R2.9.b `DepRecord` shape at the FFI boundary.
31/// Each entry represents one dep's state for the current wave:
32///
33/// - `data` — DATA handles accumulated this wave (R1.3.6.b coalescing).
34/// Empty means the dep settled RESOLVED or was not involved.
35/// - `prev_data` — last DATA handle from the end of the previous wave.
36/// [`NO_HANDLE`] if the dep has never emitted DATA.
37/// - `involved` — `true` iff the dep was dirtied-then-settled this wave.
38/// Distinguishes "RESOLVED in wave" (`involved && data.is_empty()`) from
39/// "not involved" (`!involved && data.is_empty()`).
40#[derive(Clone, Debug)]
41pub struct DepBatch {
42 /// DATA handles accumulated this wave. Outside `batch()` scope, at most
43 /// 1 element. Inside `batch()`, K consecutive emits on the same source
44 /// produce K entries per R1.3.6.b coalescing.
45 pub data: SmallVec<[HandleId; 1]>,
46 /// Last DATA handle from the end of the previous wave. [`NO_HANDLE`]
47 /// means the dep has never emitted DATA.
48 pub prev_data: HandleId,
49 /// Whether this dep was involved (dirtied → settled) in the current wave.
50 pub involved: bool,
51}
52
53impl DepBatch {
54 /// The "latest" handle for this dep — the last DATA in the current wave's
55 /// batch, falling back to `prev_data` if no DATA arrived this wave.
56 /// Returns [`NO_HANDLE`] only when the dep has never emitted.
57 #[must_use]
58 pub fn latest(&self) -> HandleId {
59 self.data.last().copied().unwrap_or(self.prev_data)
60 }
61
62 /// Convenience: is this dep in sentinel state (never emitted DATA)?
63 #[must_use]
64 pub fn is_sentinel(&self) -> bool {
65 self.prev_data == NO_HANDLE && self.data.is_empty()
66 }
67}
68
69/// Lifecycle trigger discriminator for [`BindingBoundary::cleanup_for`].
70///
71/// Slice E2 (R2.4.5 / Lock 4.A / Lock 4.A′) — the named-hook cleanup spec
72/// returned by user fns has three independent slots; Core fires each slot
73/// at its own lifecycle moment via `cleanup_for(node_id, CleanupTrigger)`.
74///
75/// All three triggers fire **lock-released** per Slice E D045 handshake
76/// discipline. The binding is responsible for resolving the trigger to its
77/// stored cleanup closure (typically a `node_id → NodeFnCleanup` map) and
78/// invoking the matching slot.
79#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
80pub enum CleanupTrigger {
81 /// **R2.4.5 `onRerun`** — fires before the next fn run within the same
82 /// activation cycle. Core fires this in `fire_regular` between the
83 /// lock-held dep-snapshot phase and the lock-released `invoke_fn` call,
84 /// gated on `has_fired_once == true` (so first-fire never sees an
85 /// `onRerun`). The user closure receives no arguments and is expected
86 /// to release fn-local resources from the previous run before the
87 /// fresh `invoke_fn` allocates new ones.
88 OnRerun,
89 /// **R2.4.5 `onDeactivation`** — fires when subscriber count drops to
90 /// zero. Core fires this from `Subscription::Drop` (alongside the
91 /// existing [`BindingBoundary::producer_deactivate`] call), gated on
92 /// `has_fired_once == true`. Order: cleanup-first (`OnDeactivation`)
93 /// then `producer_deactivate`, because cleanup may release handles the
94 /// producer subscription owns (D056). Per D059, bindings SHOULD clear
95 /// `current_cleanup` on this trigger — the next subscribe + first-fire
96 /// will re-register a fresh closure.
97 OnDeactivation,
98 /// **R2.4.5 `onInvalidate`** — fires when an `[[INVALIDATE]]` arrives at
99 /// the node and clears its cache. Per **R1.3.9.b** strict reading
100 /// (D057): fires **at most once per wave per node**, regardless of
101 /// fan-in shape. Per **R1.3.9.c**: never fires when the node's cache
102 /// is the never-populated sentinel (a node that has not yet emitted
103 /// has nothing to clean up). Per **D058**: fires at cache-clear time,
104 /// not at wire-delivery time — pause buffering doesn't defer the
105 /// hook. **Per D061**: when the wave is panic-discarded mid-flight,
106 /// queued OnInvalidate hooks are dropped silently (the cleanup never
107 /// fires for the panicked wave); see [`BindingBoundary::cleanup_for`]
108 /// rustdoc for the panic-discard guarantee gap and the bindings'
109 /// idempotent-cleanup recommendation.
110 OnInvalidate,
111}
112
113/// A single emission within a [`FnResult::Batch`] — one element of an
114/// `actions.down(msgs)` call. Processed in sequence within the same wave.
115#[derive(Clone, Debug)]
116#[must_use = "FnEmission may contain handles that must be processed or released"]
117pub enum FnEmission {
118 /// DATA payload. Processed via `commit_emission` — sets cache, queues
119 /// Dirty (if not already dirty per R1.3.1.a) + Data, propagates to
120 /// children. No equals substitution in Batch context (R1.3.2.d /
121 /// R1.3.3.c: multi-message waves pass through verbatim).
122 Data(HandleId),
123 /// COMPLETE terminal. Cascades per R1.3.4 / Lock 2.B.
124 Complete,
125 /// ERROR terminal with error-value handle. Cascades per R1.3.4;
126 /// ERROR dominates COMPLETE (Lock 2.B).
127 Error(HandleId),
128}
129
130/// What the binding side returns when the Core invokes a fn via
131/// [`BindingBoundary::invoke_fn`].
132///
133/// Models the three emission modes from the canonical spec:
134/// - `FnResult::Data` — single DATA, equals substitution applies (R1.3.2).
135/// Maps to `actions.emit(v)` in sugar constructors.
136/// - `FnResult::Batch` — multi-message wave, no equals substitution
137/// (R1.3.2.d / R1.3.3.c). Maps to `actions.down(msgs)`.
138/// - `FnResult::Noop` — no emission. RESOLVED if node was DIRTY.
139///
140/// Per R2.4.5, the fn return value in the canonical spec is cleanup hooks
141/// only — all emission is explicit via actions. The Rust Core folds the
142/// emission into `FnResult` as a pragmatic simplification; the binding
143/// layer maps between the two representations.
144#[derive(Clone, Debug)]
145pub enum FnResult {
146 /// fn produced a single value. The Core treats this as outgoing DATA —
147 /// equals-substitution against the cache may rewrite it to RESOLVED
148 /// on the wire (R1.3.2).
149 Data {
150 handle: HandleId,
151 /// For dynamic nodes only: the dep indices fn actually read this run.
152 /// Static derived nodes pass `None`. See dynamic-node semantics in the
153 /// canonical spec §2.8 / Lock 2.B.
154 tracked: Option<Vec<usize>>,
155 },
156
157 /// fn ran but produced no emission this wave. The Core sends RESOLVED
158 /// to subscribers if the node was already DIRTY this wave; otherwise no
159 /// outgoing message.
160 Noop {
161 /// Same as `Data::tracked` — dynamic nodes only.
162 tracked: Option<Vec<usize>>,
163 },
164
165 /// Multi-message wave — models `actions.down(msgs)`. Emissions are
166 /// processed in sequence within the same wave. No equals substitution
167 /// on any Data emission (R1.3.2.d: substitution only on single-DATA
168 /// waves; R1.3.3.c: multi-DATA passes verbatim). DIRTY auto-prefix
169 /// only on first Data per R1.3.1.a.
170 Batch {
171 emissions: SmallVec<[FnEmission; 2]>,
172 /// Same as `Data::tracked` — dynamic nodes only.
173 tracked: Option<Vec<usize>>,
174 },
175}
176
177/// The FFI surface: every Core → user-code crossing goes through one of these
178/// three methods.
179///
180/// # Thread safety
181///
182/// `Send + Sync` because the Core dispatcher is sync but may be called from
183/// multiple binding threads (e.g. multiple Node Workers sharing one Core via
184/// `Arc<Core>`). Implementors must serialize access to the value registry
185/// internally if needed. Free-threaded Python parity is the target — see
186/// `~/src/graphrefly-py` `compat/asyncio.py` for the current shape that
187/// will simplify dramatically once this trait is the substrate.
188pub trait BindingBoundary: Send + Sync {
189 /// Invoke a user function. The Core knows the fn's identity (`fn_id`) and
190 /// the current dep batch data; the binding side dereferences handles,
191 /// runs the fn, registers the output, and returns the new handle.
192 ///
193 /// `dep_data` carries per-dep batch arrays (R1.3.6.b coalescing): outside
194 /// `batch()` scope each dep has at most 1 DATA handle; inside `batch()`,
195 /// K consecutive emits on the same source produce K entries per dep.
196 /// The binding side bulk-dereferences all handles, calls user code, and
197 /// returns the result — one FFI call per fn fire regardless of dep count
198 /// or batch depth.
199 ///
200 /// Errors thrown by user code are reported by returning a [`FnResult::Data`]
201 /// with a handle that resolves to an error value; the Core then forwards
202 /// `[ERROR, handle]` per R1.2.5. (Or the binding side surfaces them via a
203 /// separate channel — exact error-propagation discipline is binding-side.)
204 fn invoke_fn(&self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch]) -> FnResult;
205
206 /// Invoke a user function **with the owner-side full `Core` facade in
207 /// hand** (D246 rule 5 / D245 Option A — the producer-build facade
208 /// hand-off, folding D245).
209 ///
210 /// Core calls this — not the parameterless [`Self::invoke_fn`] — for
211 /// every fn-fire dispatch ([`crate::batch`] `fire_regular` Phase 2),
212 /// passing `self as &dyn CoreFull` (the one object-safe
213 /// mutation+inspection+serialize+mailbox facade, [`crate::node::CoreFull`]).
214 /// A binding whose `invoke_fn` builds a *producer* (the operators test
215 /// harness, the napi bench binding) needs a real Core surface during
216 /// the build — `subscribe`/`register_*` for upstream wiring, not just
217 /// the emit-only mailbox. D231 located producer build/teardown
218 /// owner-side with `&Core`; D245 made that concrete at the trait
219 /// surface so the build path receives a Core facade explicitly
220 /// **without** a thread-local / `Core` clone / stored back-reference
221 /// (all β-invalid under the actor model — `Core` is move-only).
222 ///
223 /// The default forwards to [`Self::invoke_fn`] (ignoring `core`), so
224 /// every binding that does **not** build producers is completely
225 /// unaffected — core/structures/storage/graph test harnesses and the
226 /// napi non-producer paths keep their existing parameterless
227 /// `invoke_fn` semantics with zero behavior change. Only a
228 /// producer-building binding overrides this to construct its
229 /// `ProducerCtx` from the passed `&dyn CoreFull`.
230 fn invoke_fn_with_core(
231 &self,
232 node_id: NodeId,
233 fn_id: FnId,
234 dep_data: &[DepBatch],
235 core: &dyn crate::node::CoreFull,
236 ) -> FnResult {
237 let _ = core;
238 self.invoke_fn(node_id, fn_id, dep_data)
239 }
240
241 /// Custom equals oracle. Called only when a node declares
242 /// `EqualsMode::Custom`. Identity equals (the default) is a `u64` compare
243 /// inside the Core — zero FFI per check.
244 ///
245 /// Per `H2 IdentityEqualsIsPureCore` ASSUME in
246 /// `~/src/graphrefly-ts/docs/research/handle-protocol.tla`,
247 /// the binding-side impl MUST extend identity (i.e. always treat
248 /// `a == b` as equal at the handle level). Otherwise a node could observe
249 /// its own cached value as different from itself — a fundamental violation.
250 fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool;
251
252 /// Decrement the refcount on `handle`. Called when the Core no longer
253 /// holds `handle` in any cache slot or message buffer. The binding side
254 /// drops the underlying value when its refcount reaches zero.
255 ///
256 /// Implementing this as a no-op is safe during prototyping; it matters
257 /// for memory pressure under sustained load. The TS prototype's
258 /// `bindings.ts` uses this to drive a `Map<HandleId, { value, refcount }>`.
259 ///
260 /// # Leaf-operation contract (/qa F2/M1, 2026-05-10) — HARD requirement
261 ///
262 /// Implementations MUST NOT re-enter Core from `release_handle`. The
263 /// Core's lock-held release paths (`Drop for CoreState`,
264 /// `Core::reset_for_fresh_lifecycle` Phase 3 / 3b / 5,
265 /// `OperatorScratch::release_handles` callers) invoke
266 /// `release_handle` while the state mutex is held; re-entering Core
267 /// (via `emit` / `subscribe` / `register` / nested `release_handle`
268 /// on a different handle that triggers a final-Drop hook into Core,
269 /// etc.) deadlocks against that lock.
270 ///
271 /// "Re-entry into Core" here means: calling any method on the same
272 /// `Core` instance (or any `Core` that shares state via `Arc::clone`
273 /// of the inner state mutex). Independent Cores are fine.
274 ///
275 /// Safe operations inside `release_handle`:
276 /// - Pure value-registry bookkeeping (decrement refcount, drop the
277 /// underlying `T` when count reaches zero).
278 /// - Logging / metrics that don't re-enter Core.
279 /// - Calling other binding methods that themselves honor the
280 /// leaf-op contract (e.g., a binding-internal mutex).
281 ///
282 /// Forbidden operations inside `release_handle`:
283 /// - Any `Core::emit` / `subscribe` / `register*` / `complete` /
284 /// `error` / `teardown` / `invalidate` / `pause` / `resume` /
285 /// `set_deps` / `batch` / `begin_batch`.
286 /// - Recursive `BindingBoundary::release_handle` on this binding
287 /// that fans into Core via a binding-side final-Drop.
288 ///
289 /// Bindings that need lifecycle hooks on value-drop should buffer
290 /// the events and process them asynchronously (e.g., next tick on
291 /// the host runtime) so the Core lock is released before
292 /// re-entrance.
293 fn release_handle(&self, handle: HandleId);
294
295 /// Increment the refcount on `handle`. Called when the Core takes an
296 /// additional reference to an existing handle that the binding side
297 /// already interned — used by the pause buffer (a buffered
298 /// `Data(H)` outlives the cache slot that originally interned `H`, so
299 /// the buffer needs its own refcount share to keep `H` alive across
300 /// later cache replacements), the operator-scratch pipeline (Scan/Reduce
301 /// seed retain, Last default retain), and Phase G's D-α fresh-scratch
302 /// install.
303 ///
304 /// Default: no-op. Bindings that don't track refcounts (e.g., trivial
305 /// always-alive registries) can leave the default. Bindings that do
306 /// (TS `bindings.ts`, the test runtime, the napi-rs bench harness) must
307 /// override to bump the per-handle refcount.
308 ///
309 /// # Leaf-operation contract — HARD requirement
310 ///
311 /// Same leaf-op contract as [`Self::release_handle`]: implementations
312 /// MUST NOT re-enter Core from `retain_handle`. Core call sites
313 /// (notably `make_op_scratch_with_binding` called from Phase G's
314 /// lock-held window) assume the retain is a pure refcount bump.
315 fn retain_handle(&self, _handle: HandleId) {}
316
317 // -----------------------------------------------------------------
318 // Operator FFI surface (Slice C-1, D009). Bulk projection methods
319 // for the built-in operator dispatch path. Default impls panic so
320 // bindings that don't ship operators (e.g., minimal test bindings
321 // that only exercise raw fn-fire) don't pay the registry cost; the
322 // dispatch path only routes here when an `Operator(_)` node fires,
323 // so a binding that doesn't register operator nodes never reaches
324 // these defaults.
325 //
326 // Each method returns the per-input result. Caller (Core) owns the
327 // resulting handles' retains — the binding must `retain_handle`-bump
328 // any handles it returns that share an existing registry slot.
329 // -----------------------------------------------------------------
330
331 /// `OperatorOp::Map` — element-wise transform. For each input handle,
332 /// the binding dereferences to `T`, calls the user `Fn(T) -> R`, and
333 /// returns the new handle. Output length equals input length.
334 fn project_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[HandleId; 1]> {
335 unimplemented!("project_each: this binding does not support operators (D009)")
336 }
337
338 /// `OperatorOp::Filter` — element-wise predicate. For each input
339 /// handle, the binding dereferences to `T`, calls the user
340 /// `Fn(T) -> bool`, and returns the boolean. Output length equals
341 /// input length.
342 fn predicate_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[bool; 4]> {
343 unimplemented!("predicate_each: this binding does not support operators (D009)")
344 }
345
346 /// `OperatorOp::Scan` / `OperatorOp::Reduce` — left-fold. The binding
347 /// dereferences `acc` to `R` and each input to `T`, runs
348 /// `Fn(R, T) -> R` for each input feeding the previous result forward,
349 /// and returns each intermediate `R` as a fresh handle. Output length
350 /// equals input length (Scan emits each entry; Reduce uses only the
351 /// last). The starting `acc` is owned by Core; the binding must NOT
352 /// release it.
353 fn fold_each(
354 &self,
355 _fn_id: FnId,
356 _acc: HandleId,
357 _inputs: &[HandleId],
358 ) -> SmallVec<[HandleId; 1]> {
359 unimplemented!("fold_each: this binding does not support operators (D009)")
360 }
361
362 /// `OperatorOp::Pairwise` — pack `(prev, current)` into a tuple value.
363 /// Called once per pair (Core iterates and updates `prev` between
364 /// calls). Returns the binding-side handle for the new tuple value.
365 fn pairwise_pack(&self, _fn_id: FnId, _prev: HandleId, _current: HandleId) -> HandleId {
366 unimplemented!("pairwise_pack: this binding does not support operators (D009)")
367 }
368
369 /// `OperatorOp::Combine` / `OperatorOp::WithLatestFrom` — pack N
370 /// handles into a single tuple/array handle (Slice C-2, D020). The
371 /// binding dereferences each input handle to `T`, constructs a tuple
372 /// value (e.g., `[T0, T1, ..., Tn]`), and returns the new handle.
373 /// The returned handle has a pre-bumped retain (caller takes ownership
374 /// without additional `retain_handle` call).
375 fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId {
376 unimplemented!("pack_tuple: this binding does not support combinator operators (D020)")
377 }
378
379 /// Intern a [`NodeId`] as a value handle. Used by windowing operators
380 /// (`window`, `window_count`) to emit inner sub-node identities as
381 /// DATA payloads. The binding side stores the node id as a value in
382 /// its value registry and returns the corresponding [`HandleId`].
383 /// The returned handle has a pre-bumped retain (caller takes ownership).
384 ///
385 /// Default panics — bindings that ship window operators MUST override.
386 fn intern_node(&self, _node_id: NodeId) -> HandleId {
387 unimplemented!("intern_node: this binding does not support window operators")
388 }
389
390 // -----------------------------------------------------------------
391 // Producer lifecycle (Slice D, D031, D035). Producers are nodes
392 // with no deps + a fn — fn fires once on first subscribe and may
393 // call `Core::subscribe` from inside its body to wire up upstream
394 // sources (the zip / concat / race / takeUntil pattern).
395 //
396 // The binding maintains its own per-producer state (subscription
397 // handles, captured closure state) outside Core. When the LAST
398 // subscriber unsubscribes from a producer, Core invokes
399 // `producer_deactivate(node_id)` so the binding can drop that
400 // state — which transitively drops the producer's upstream
401 // subscriptions via `Subscription::Drop`.
402 //
403 // The hook fires lock-released (after the state lock is dropped),
404 // so the binding's deactivation impl may re-enter Core if needed
405 // (e.g., calling `release_handle` on captured handle shares).
406 //
407 // Symmetric with FnCtx: Core hands the binding a lifecycle signal
408 // and lets the binding shape its ergonomic surface (the
409 // `ProducerCtx` helper in `graphrefly-operators::producer` is one
410 // such shape; bindings may roll their own).
411 // -----------------------------------------------------------------
412
413 /// Called when a producer node loses its last subscriber. The binding
414 /// should drop any per-node state for `node_id` — captured closures,
415 /// recorded upstream `(NodeId, SubscriptionId)` pairs, etc. Default
416 /// no-op for bindings that don't ship producers.
417 ///
418 /// Fires lock-released; re-entrance into Core is permitted.
419 ///
420 /// S2b / D229: core-level RAII `Subscription` is retired (D223/D225 —
421 /// a parameterless `Drop` cannot reach a relocating owned `Core`). The
422 /// binding therefore records upstream subs as `(NodeId,
423 /// SubscriptionId)` and unsubscribes them HERE via `unsub` — a
424 /// `Core::unsubscribe`-capable closure the owner-driven `unsubscribe`
425 /// chain passes in (it holds the `&Core` already, exactly the
426 /// synchronous owner context D225 requires; no `Weak<C>`, no
427 /// `unsafe`, no parameterless-`Drop`). Calling `unsub(up_node,
428 /// up_sub)` is behaviour-identical to the old `Subscription::Drop`
429 /// (it runs the same deregister + Phase-G chain, and re-entrant
430 /// producer cascades work because the call is lock-released).
431 fn producer_deactivate(&self, _node_id: NodeId, _unsub: &dyn Fn(NodeId, SubscriptionId)) {}
432
433 /// R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused
434 /// node's pause buffer overflows the configured cap. Called once per
435 /// overflow event (the first drop of a pause cycle); the returned
436 /// handle becomes the payload of `Message::Error` that Core then
437 /// emits via the standard terminal cascade.
438 ///
439 /// Bindings that ship their own diagnostic shape (e.g. structured
440 /// `{ code, nodeId, dropped, configuredMax, lockHeldDurationMs }`
441 /// JSON for the JS / Python sides) override this to intern such a
442 /// value and return its handle.
443 ///
444 /// **Default returns `None`** — Rust core falls back to the silent
445 /// drop + `ResumeReport.dropped` path. This preserves backward
446 /// compatibility for bindings that haven't yet wired up R1.3.8.c.
447 /// New bindings SHOULD implement this method to satisfy the
448 /// canonical-spec invariant.
449 ///
450 /// **Returning `None` consumes the overflow event for the cycle**
451 /// (QA A9, 2026-05-07): the per-pause-cycle `overflow_reported` flag
452 /// is set BEFORE this hook is called, so a `None` return doesn't
453 /// re-attempt synthesis on subsequent overflows in the same cycle.
454 /// If the binding is going to dynamically wire up the hook
455 /// (configuration-driven), do so before any pause cycle that may
456 /// overflow.
457 ///
458 /// Fires lock-released. The binding may re-enter Core (typical
459 /// implementation just calls `intern_value` and returns).
460 ///
461 /// `lock_held_duration_ms` is the wall-clock-monotonic duration in
462 /// milliseconds since the node first transitioned `Active → Paused`
463 /// at the start of this pause cycle; it gives consumers a sense of
464 /// how long a leaked controller held the lockset before overflow.
465 /// Sub-millisecond durations truncate to `0` (QA A8, 2026-05-07).
466 fn synthesize_pause_overflow_error(
467 &self,
468 _node_id: NodeId,
469 _dropped_count: u32,
470 _configured_max: usize,
471 _lock_held_duration_ms: u64,
472 ) -> Option<HandleId> {
473 None
474 }
475
476 // -----------------------------------------------------------------
477 // Cleanup-hook lifecycle (Slice E2 — R2.4.5 / R2.4.6 / Lock 4.A / 4.A′
478 // / Lock 6.D). Decisions: D054 (lifecycle-trigger hooks; binding owns
479 // ctx state), D055 (binding-side `Mutex<HashMap<NodeId, NodeCtxState>>`,
480 // wipe only on resubscribable terminal reset), D056 (cleanup-first
481 // before producer_deactivate), D057 (strict per-wave-per-node dedup),
482 // D058 (fire at cache-clear time), D059 (one-shot current_cleanup on
483 // OnDeactivation), D060 (binding-side panic isolation, drain
484 // iterates-don't-short-circuit), D061 (panic-discard wave drops
485 // deferred queue silently). Full design lives in
486 // `~/src/graphrefly-ts/archive/docs/SESSION-rust-port-fn-ctx-cleanup.md`.
487 //
488 // Bindings opt in by overriding `cleanup_for` and `wipe_ctx`. Default
489 // no-ops keep non-cleanup-aware bindings (e.g. minimal test bindings,
490 // bench harnesses) compiling unchanged.
491 // -----------------------------------------------------------------
492
493 /// Fire a registered user-cleanup hook for `node_id` at the lifecycle
494 /// moment indicated by `trigger`.
495 ///
496 /// # Binding-side contract
497 ///
498 /// Per D055, bindings own a `Mutex<HashMap<NodeId, NodeCtxState>>` where
499 /// `NodeCtxState = { store, current_cleanup }`. On each `invoke_fn` the
500 /// binding overwrites `current_cleanup` with whatever cleanup spec the
501 /// user fn returned. When Core later fires `cleanup_for(node, trigger)`,
502 /// the binding's impl looks up `current_cleanup.<trigger_slot>` and
503 /// invokes it if present. Absent slots are no-ops.
504 ///
505 /// **Lock discipline.** Bindings MUST release their `node_ctx` lock
506 /// before invoking the user closure (clone the closure handle out of
507 /// the map, drop the lock, fire). Holding the binding-side lock
508 /// across the user closure deadlocks if the user re-enters the
509 /// binding's high-level API from inside the cleanup.
510 ///
511 /// **Re-entrance into Core (D045 / D060).** Permitted: `release_handle`,
512 /// `Core::up(other_node, Pause/Resume/Invalidate/Teardown)`, `Core::emit`
513 /// for unrelated nodes, `Core::subscribe` / drop a `Subscription`. Not
514 /// permitted (undefined behavior): `Core::emit(self_node, ...)` from
515 /// inside `OnRerun` (creates a fresh emit during the wave's pre-fire
516 /// window); `Core::subscribe(self_node)` from inside `OnDeactivation`
517 /// (self-resubscribe race during deactivation).
518 ///
519 /// **Panic isolation (D060).** Core stays panic-naive about user code
520 /// — bindings SHOULD wrap user-closure invocations in `catch_unwind`
521 /// and surface failures via the host language's idiom (JS exception
522 /// → console.error, Python panic → warning, Rust panic → log + propagate
523 /// per binding policy). Core's deferred-drain for `OnInvalidate` wraps
524 /// each `cleanup_for` call in `catch_unwind` itself to prevent a single
525 /// panic from short-circuiting the per-wave drain (all queued cleanup
526 /// attempts run; the last panic re-raises after the drain completes).
527 /// `OnRerun` and `OnDeactivation` fire inline lock-released — a
528 /// panicking `cleanup_for` propagates out of `fire_regular` Phase 1.5
529 /// or `Subscription::Drop` respectively (Drop guarantees apply: state
530 /// lock already released).
531 ///
532 /// **Panic-discard guarantee gap (D061).** When a wave is panic-discarded
533 /// (an `invoke_fn` panics mid-wave), the queued `OnInvalidate` cleanup
534 /// hooks are dropped silently — the cascade's recorded cache-clears
535 /// never fire their cleanups. External-resource cleanup (file handles,
536 /// network sockets, external transactions) attached to `OnInvalidate`
537 /// MUST be idempotent at process exit / next successful invalidate
538 /// cycle. `OnRerun` panic-discard is moot (panic in `OnRerun` aborts
539 /// the wave's `fire_regular` before it can corrupt state). `OnDeactivation`
540 /// panic-discard is moot (Drop is invoked during stack unwinding;
541 /// double-panic during drop aborts per `std::process::abort` semantics).
542 ///
543 /// **Per-trigger lifecycle.** See [`CleanupTrigger`] for per-variant
544 /// firing rules. After each trigger fires, the binding decides whether
545 /// to clear `current_cleanup`:
546 /// - `OnRerun`: do NOT clear; the next `invoke_fn` will overwrite.
547 /// - `OnInvalidate`: do NOT clear; multiple INVALIDATEs across waves
548 /// can re-fire the same closure.
549 /// - `OnDeactivation`: clear (D059) — one-shot per activation cycle;
550 /// `store` persists separately per R2.4.6.
551 ///
552 /// Default no-op so bindings without cleanup-hook support compile
553 /// unchanged.
554 fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) {}
555
556 // -----------------------------------------------------------------
557 // Snapshot serialization (M4.E1 — D166). The Core operates on
558 // opaque HandleId integers; snapshot persistence needs to cross
559 // the cleaving plane to serialize/deserialize user values as JSON.
560 // Default impls return None / panic so bindings without snapshot
561 // support compile unchanged.
562 // -----------------------------------------------------------------
563
564 /// Serialize a handle's value to JSON for snapshot persistence.
565 /// Returns `None` if the handle is unknown or unresolvable.
566 ///
567 /// Called by `Graph::snapshot()` for each node's cache handle.
568 /// The binding dereferences `handle` to its underlying `T` and
569 /// produces a `serde_json::Value` that will survive serialization
570 /// round-trips (i.e., JSON-safe: no functions, no circular refs).
571 ///
572 /// Default returns `None` — bindings that don't support snapshots
573 /// get `value: null` in the snapshot output.
574 fn serialize_handle(&self, _handle: HandleId) -> Option<serde_json::Value> {
575 None
576 }
577
578 /// Deserialize a JSON value back into a handle for snapshot restore.
579 /// The binding interns the value and returns its `HandleId`.
580 ///
581 /// Called by `Graph::restore()` / `Graph::from_snapshot()` for each
582 /// node's serialized value.
583 ///
584 /// Default panics — bindings that restore snapshots MUST override.
585 fn deserialize_value(&self, _value: serde_json::Value) -> HandleId {
586 unimplemented!("deserialize_value: this binding does not support snapshot restore (D166)")
587 }
588
589 // -----------------------------------------------------------------
590 // Control-operator FFI surface (tap, rescue). Side-effect and
591 // error-recovery callbacks invoked by control operators in
592 // `graphrefly-operators::control`. Default impls panic so bindings
593 // that don't ship control operators compile unchanged.
594 // -----------------------------------------------------------------
595
596 /// Side-effect tap: invoke a user callback with a DATA handle for
597 /// observation purposes. The callback must NOT produce a return
598 /// value or modify the handle's refcount — it is purely for
599 /// side-effects (logging, metrics, debugging).
600 ///
601 /// Called by `tap` and `on_first_data` operators on each (or first)
602 /// DATA emission.
603 fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId) {
604 unimplemented!("invoke_tap_fn: this binding does not support control operators")
605 }
606
607 /// Side-effect tap on ERROR: invoke a user callback with the error
608 /// handle. Purely for observation — must not modify refcounts.
609 fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId) {
610 unimplemented!("invoke_tap_error_fn: this binding does not support control operators")
611 }
612
613 /// Side-effect tap on COMPLETE: invoke a user callback.
614 fn invoke_tap_complete_fn(&self, _fn_id: FnId) {
615 unimplemented!("invoke_tap_complete_fn: this binding does not support control operators")
616 }
617
618 /// Error recovery: invoke a user callback with the error handle.
619 /// Returns `Ok(recovered_handle)` if recovery succeeded (the
620 /// binding interns the recovered value and returns its handle with
621 /// a pre-bumped retain). Returns `Err(())` if recovery failed and
622 /// the original error should propagate.
623 #[allow(clippy::result_unit_err)]
624 fn invoke_rescue_fn(&self, _fn_id: FnId, _handle: HandleId) -> Result<HandleId, ()> {
625 unimplemented!("invoke_rescue_fn: this binding does not support control operators")
626 }
627
628 /// Stratify classifier — invoke a user predicate with BOTH the
629 /// latest rules handle AND the current value handle. Returns
630 /// `true` if the value belongs to this branch.
631 ///
632 /// Called by `graphrefly_operators::stratify_branch` on each
633 /// source DATA. The binding-side closure (registered via
634 /// `OperatorBinding::register_stratify_classifier`) typically
635 /// dereferences both handles, looks up its branch's rule by name
636 /// inside the rules array, and runs the rule's `classify(value)`.
637 /// Returning `false` for "rule not found" or "classifier threw"
638 /// matches TS stratify semantics.
639 ///
640 /// Default panics — bindings that ship the stratify operator MUST
641 /// override.
642 fn invoke_stratify_classifier_fn(
643 &self,
644 _fn_id: FnId,
645 _rules_handle: HandleId,
646 _value_handle: HandleId,
647 ) -> bool {
648 unimplemented!("invoke_stratify_classifier_fn: this binding does not support the stratify operator (D199)")
649 }
650
651 /// Wipe the binding-side ctx state for `node_id`.
652 ///
653 /// Called by Core ONLY on resubscribable terminal reset, per **R2.4.6**:
654 /// `ctx.store` is "wiped automatically: on resubscribable terminal
655 /// reset (when a `resubscribable: true` node hits `COMPLETE`/`ERROR`
656 /// and is later resubscribed)". Bindings drop their `NodeCtxState`
657 /// entry for `node_id`, releasing both `store` and any residual
658 /// `current_cleanup`.
659 ///
660 /// Default deactivation does NOT trigger wipe — `store` persists
661 /// across deactivation/reactivation cycles by spec design (mirrored
662 /// here to match canonical; current TS impl wipes on deactivation
663 /// per docstring at `node.ts:189-190` and is on the Phase 13.6.B
664 /// migration list per canonical-spec §11 item 3).
665 ///
666 /// Fires lock-released. Re-entrance into Core is permitted; typical
667 /// implementations just call `node_ctx.lock().remove(&node_id)`.
668 /// Default no-op.
669 fn wipe_ctx(&self, _node_id: NodeId) {}
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use crate::handle::{FnId, HandleId, NodeId};
676 use std::sync::atomic::{AtomicU64, Ordering};
677
678 /// Test double mirroring `bindings.ts` `BindingBoundary` test patterns —
679 /// counts FFI crossings per method to verify the cleaving plane's
680 /// "zero FFI on identity-equals path" claim experimentally.
681 #[allow(clippy::struct_field_names)]
682 struct TestBinding {
683 invoke_count: AtomicU64,
684 equals_count: AtomicU64,
685 release_count: AtomicU64,
686 }
687
688 impl TestBinding {
689 fn new() -> Self {
690 Self {
691 invoke_count: AtomicU64::new(0),
692 equals_count: AtomicU64::new(0),
693 release_count: AtomicU64::new(0),
694 }
695 }
696 }
697
698 impl BindingBoundary for TestBinding {
699 fn invoke_fn(&self, _node_id: NodeId, _fn_id: FnId, dep_data: &[DepBatch]) -> FnResult {
700 self.invoke_count.fetch_add(1, Ordering::SeqCst);
701 // Echo first dep's latest handle as result; not realistic but exercises the path.
702 let handle = dep_data.first().map_or(HandleId::new(99), DepBatch::latest);
703 FnResult::Data {
704 handle,
705 tracked: None,
706 }
707 }
708
709 fn custom_equals(&self, _equals_handle: FnId, a: HandleId, b: HandleId) -> bool {
710 self.equals_count.fetch_add(1, Ordering::SeqCst);
711 a == b
712 }
713
714 fn release_handle(&self, _handle: HandleId) {
715 self.release_count.fetch_add(1, Ordering::SeqCst);
716 }
717 }
718
719 #[test]
720 fn boundary_calls_route_correctly() {
721 let b = TestBinding::new();
722 let dep = DepBatch {
723 data: smallvec::smallvec![HandleId::new(7)],
724 prev_data: NO_HANDLE,
725 involved: true,
726 };
727 let result = b.invoke_fn(NodeId::new(1), FnId::new(2), &[dep]);
728 match result {
729 FnResult::Data { handle, .. } => assert_eq!(handle, HandleId::new(7)),
730 FnResult::Noop { .. } | FnResult::Batch { .. } => panic!("expected Data variant"),
731 }
732 assert!(b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(7)));
733 assert!(!b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(8)));
734 b.release_handle(HandleId::new(7));
735
736 assert_eq!(b.invoke_count.load(Ordering::SeqCst), 1);
737 assert_eq!(b.equals_count.load(Ordering::SeqCst), 2);
738 assert_eq!(b.release_count.load(Ordering::SeqCst), 1);
739 }
740
741 #[test]
742 fn binding_is_send_and_sync() {
743 // Compile-time check: BindingBoundary impls must be Send + Sync.
744 // If TestBinding ever gains a !Send field, this fails to compile.
745 fn assert_send_sync<T: Send + Sync>() {}
746 // dyn-trait variant is the production shape (Core holds Arc<dyn BindingBoundary>).
747 fn assert_dyn_send_sync<T: ?Sized + Send + Sync>() {}
748 assert_send_sync::<TestBinding>();
749 assert_dyn_send_sync::<dyn BindingBoundary>();
750 }
751}