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};
26
27/// Per-dep batch data passed to [`BindingBoundary::invoke_fn`].
28///
29/// Mirrors the canonical spec R2.9.b `DepRecord` shape at the FFI boundary.
30/// Each entry represents one dep's state for the current wave:
31///
32/// - `data` — DATA handles accumulated this wave (R1.3.6.b coalescing).
33/// Empty means the dep settled RESOLVED or was not involved.
34/// - `prev_data` — last DATA handle from the end of the previous wave.
35/// [`NO_HANDLE`] if the dep has never emitted DATA.
36/// - `involved` — `true` iff the dep was dirtied-then-settled this wave.
37/// Distinguishes "RESOLVED in wave" (`involved && data.is_empty()`) from
38/// "not involved" (`!involved && data.is_empty()`).
39#[derive(Clone, Debug)]
40pub struct DepBatch {
41 /// DATA handles accumulated this wave. Outside `batch()` scope, at most
42 /// 1 element. Inside `batch()`, K consecutive emits on the same source
43 /// produce K entries per R1.3.6.b coalescing.
44 pub data: SmallVec<[HandleId; 1]>,
45 /// Last DATA handle from the end of the previous wave. [`NO_HANDLE`]
46 /// means the dep has never emitted DATA.
47 pub prev_data: HandleId,
48 /// Whether this dep was involved (dirtied → settled) in the current wave.
49 pub involved: bool,
50}
51
52impl DepBatch {
53 /// The "latest" handle for this dep — the last DATA in the current wave's
54 /// batch, falling back to `prev_data` if no DATA arrived this wave.
55 /// Returns [`NO_HANDLE`] only when the dep has never emitted.
56 #[must_use]
57 pub fn latest(&self) -> HandleId {
58 self.data.last().copied().unwrap_or(self.prev_data)
59 }
60
61 /// Convenience: is this dep in sentinel state (never emitted DATA)?
62 #[must_use]
63 pub fn is_sentinel(&self) -> bool {
64 self.prev_data == NO_HANDLE && self.data.is_empty()
65 }
66}
67
68/// Lifecycle trigger discriminator for [`BindingBoundary::cleanup_for`].
69///
70/// Slice E2 (R2.4.5 / Lock 4.A / Lock 4.A′) — the named-hook cleanup spec
71/// returned by user fns has three independent slots; Core fires each slot
72/// at its own lifecycle moment via `cleanup_for(node_id, CleanupTrigger)`.
73///
74/// All three triggers fire **lock-released** per Slice E D045 handshake
75/// discipline. The binding is responsible for resolving the trigger to its
76/// stored cleanup closure (typically a `node_id → NodeFnCleanup` map) and
77/// invoking the matching slot.
78#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
79pub enum CleanupTrigger {
80 /// **R2.4.5 `onRerun`** — fires before the next fn run within the same
81 /// activation cycle. Core fires this in `fire_regular` between the
82 /// lock-held dep-snapshot phase and the lock-released `invoke_fn` call,
83 /// gated on `has_fired_once == true` (so first-fire never sees an
84 /// `onRerun`). The user closure receives no arguments and is expected
85 /// to release fn-local resources from the previous run before the
86 /// fresh `invoke_fn` allocates new ones.
87 OnRerun,
88 /// **R2.4.5 `onDeactivation`** — fires when subscriber count drops to
89 /// zero. Core fires this from `Subscription::Drop` (alongside the
90 /// existing [`BindingBoundary::producer_deactivate`] call), gated on
91 /// `has_fired_once == true`. Order: cleanup-first (`OnDeactivation`)
92 /// then `producer_deactivate`, because cleanup may release handles the
93 /// producer subscription owns (D056). Per D059, bindings SHOULD clear
94 /// `current_cleanup` on this trigger — the next subscribe + first-fire
95 /// will re-register a fresh closure.
96 OnDeactivation,
97 /// **R2.4.5 `onInvalidate`** — fires when an `[[INVALIDATE]]` arrives at
98 /// the node and clears its cache. Per **R1.3.9.b** strict reading
99 /// (D057): fires **at most once per wave per node**, regardless of
100 /// fan-in shape. Per **R1.3.9.c**: never fires when the node's cache
101 /// is the never-populated sentinel (a node that has not yet emitted
102 /// has nothing to clean up). Per **D058**: fires at cache-clear time,
103 /// not at wire-delivery time — pause buffering doesn't defer the
104 /// hook. **Per D061**: when the wave is panic-discarded mid-flight,
105 /// queued OnInvalidate hooks are dropped silently (the cleanup never
106 /// fires for the panicked wave); see [`BindingBoundary::cleanup_for`]
107 /// rustdoc for the panic-discard guarantee gap and the bindings'
108 /// idempotent-cleanup recommendation.
109 OnInvalidate,
110}
111
112/// A single emission within a [`FnResult::Batch`] — one element of an
113/// `actions.down(msgs)` call. Processed in sequence within the same wave.
114#[derive(Clone, Debug)]
115#[must_use = "FnEmission may contain handles that must be processed or released"]
116pub enum FnEmission {
117 /// DATA payload. Processed via `commit_emission` — sets cache, queues
118 /// Dirty (if not already dirty per R1.3.1.a) + Data, propagates to
119 /// children. No equals substitution in Batch context (R1.3.2.d /
120 /// R1.3.3.c: multi-message waves pass through verbatim).
121 Data(HandleId),
122 /// COMPLETE terminal. Cascades per R1.3.4 / Lock 2.B.
123 Complete,
124 /// ERROR terminal with error-value handle. Cascades per R1.3.4;
125 /// ERROR dominates COMPLETE (Lock 2.B).
126 Error(HandleId),
127}
128
129/// What the binding side returns when the Core invokes a fn via
130/// [`BindingBoundary::invoke_fn`].
131///
132/// Models the three emission modes from the canonical spec:
133/// - `FnResult::Data` — single DATA, equals substitution applies (R1.3.2).
134/// Maps to `actions.emit(v)` in sugar constructors.
135/// - `FnResult::Batch` — multi-message wave, no equals substitution
136/// (R1.3.2.d / R1.3.3.c). Maps to `actions.down(msgs)`.
137/// - `FnResult::Noop` — no emission. RESOLVED if node was DIRTY.
138///
139/// Per R2.4.5, the fn return value in the canonical spec is cleanup hooks
140/// only — all emission is explicit via actions. The Rust Core folds the
141/// emission into `FnResult` as a pragmatic simplification; the binding
142/// layer maps between the two representations.
143#[derive(Clone, Debug)]
144pub enum FnResult {
145 /// fn produced a single value. The Core treats this as outgoing DATA —
146 /// equals-substitution against the cache may rewrite it to RESOLVED
147 /// on the wire (R1.3.2).
148 Data {
149 handle: HandleId,
150 /// For dynamic nodes only: the dep indices fn actually read this run.
151 /// Static derived nodes pass `None`. See dynamic-node semantics in the
152 /// canonical spec §2.8 / Lock 2.B.
153 tracked: Option<Vec<usize>>,
154 },
155
156 /// fn ran but produced no emission this wave. The Core sends RESOLVED
157 /// to subscribers if the node was already DIRTY this wave; otherwise no
158 /// outgoing message.
159 Noop {
160 /// Same as `Data::tracked` — dynamic nodes only.
161 tracked: Option<Vec<usize>>,
162 },
163
164 /// Multi-message wave — models `actions.down(msgs)`. Emissions are
165 /// processed in sequence within the same wave. No equals substitution
166 /// on any Data emission (R1.3.2.d: substitution only on single-DATA
167 /// waves; R1.3.3.c: multi-DATA passes verbatim). DIRTY auto-prefix
168 /// only on first Data per R1.3.1.a.
169 Batch {
170 emissions: SmallVec<[FnEmission; 2]>,
171 /// Same as `Data::tracked` — dynamic nodes only.
172 tracked: Option<Vec<usize>>,
173 },
174}
175
176/// The FFI surface: every Core → user-code crossing goes through one of these
177/// three methods.
178///
179/// # Thread safety
180///
181/// `Send + Sync` because the Core dispatcher is sync but may be called from
182/// multiple binding threads (e.g. multiple Node Workers sharing one Core via
183/// `Arc<Core>`). Implementors must serialize access to the value registry
184/// internally if needed. Free-threaded Python parity is the target — see
185/// `~/src/graphrefly-py` `compat/asyncio.py` for the current shape that
186/// will simplify dramatically once this trait is the substrate.
187pub trait BindingBoundary: Send + Sync {
188 /// Invoke a user function. The Core knows the fn's identity (`fn_id`) and
189 /// the current dep batch data; the binding side dereferences handles,
190 /// runs the fn, registers the output, and returns the new handle.
191 ///
192 /// `dep_data` carries per-dep batch arrays (R1.3.6.b coalescing): outside
193 /// `batch()` scope each dep has at most 1 DATA handle; inside `batch()`,
194 /// K consecutive emits on the same source produce K entries per dep.
195 /// The binding side bulk-dereferences all handles, calls user code, and
196 /// returns the result — one FFI call per fn fire regardless of dep count
197 /// or batch depth.
198 ///
199 /// Errors thrown by user code are reported by returning a [`FnResult::Data`]
200 /// with a handle that resolves to an error value; the Core then forwards
201 /// `[ERROR, handle]` per R1.2.5. (Or the binding side surfaces them via a
202 /// separate channel — exact error-propagation discipline is binding-side.)
203 fn invoke_fn(&self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch]) -> FnResult;
204
205 /// Custom equals oracle. Called only when a node declares
206 /// `EqualsMode::Custom`. Identity equals (the default) is a `u64` compare
207 /// inside the Core — zero FFI per check.
208 ///
209 /// Per `H2 IdentityEqualsIsPureCore` ASSUME in
210 /// `~/src/graphrefly-ts/docs/research/handle-protocol.tla`,
211 /// the binding-side impl MUST extend identity (i.e. always treat
212 /// `a == b` as equal at the handle level). Otherwise a node could observe
213 /// its own cached value as different from itself — a fundamental violation.
214 fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool;
215
216 /// Decrement the refcount on `handle`. Called when the Core no longer
217 /// holds `handle` in any cache slot or message buffer. The binding side
218 /// drops the underlying value when its refcount reaches zero.
219 ///
220 /// Implementing this as a no-op is safe during prototyping; it matters
221 /// for memory pressure under sustained load. The TS prototype's
222 /// `bindings.ts` uses this to drive a `Map<HandleId, { value, refcount }>`.
223 fn release_handle(&self, handle: HandleId);
224
225 /// Increment the refcount on `handle`. Called when the Core takes an
226 /// additional reference to an existing handle that the binding side
227 /// already interned — currently used by the pause buffer (a buffered
228 /// `Data(H)` outlives the cache slot that originally interned `H`, so
229 /// the buffer needs its own refcount share to keep `H` alive across
230 /// later cache replacements).
231 ///
232 /// Default: no-op. Bindings that don't track refcounts (e.g., trivial
233 /// always-alive registries) can leave the default. Bindings that do
234 /// (TS `bindings.ts`, the test runtime, the napi-rs bench harness) must
235 /// override to bump the per-handle refcount.
236 fn retain_handle(&self, _handle: HandleId) {}
237
238 // -----------------------------------------------------------------
239 // Operator FFI surface (Slice C-1, D009). Bulk projection methods
240 // for the built-in operator dispatch path. Default impls panic so
241 // bindings that don't ship operators (e.g., minimal test bindings
242 // that only exercise raw fn-fire) don't pay the registry cost; the
243 // dispatch path only routes here when an `Operator(_)` node fires,
244 // so a binding that doesn't register operator nodes never reaches
245 // these defaults.
246 //
247 // Each method returns the per-input result. Caller (Core) owns the
248 // resulting handles' retains — the binding must `retain_handle`-bump
249 // any handles it returns that share an existing registry slot.
250 // -----------------------------------------------------------------
251
252 /// `OperatorOp::Map` — element-wise transform. For each input handle,
253 /// the binding dereferences to `T`, calls the user `Fn(T) -> R`, and
254 /// returns the new handle. Output length equals input length.
255 fn project_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[HandleId; 1]> {
256 unimplemented!("project_each: this binding does not support operators (D009)")
257 }
258
259 /// `OperatorOp::Filter` — element-wise predicate. For each input
260 /// handle, the binding dereferences to `T`, calls the user
261 /// `Fn(T) -> bool`, and returns the boolean. Output length equals
262 /// input length.
263 fn predicate_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[bool; 4]> {
264 unimplemented!("predicate_each: this binding does not support operators (D009)")
265 }
266
267 /// `OperatorOp::Scan` / `OperatorOp::Reduce` — left-fold. The binding
268 /// dereferences `acc` to `R` and each input to `T`, runs
269 /// `Fn(R, T) -> R` for each input feeding the previous result forward,
270 /// and returns each intermediate `R` as a fresh handle. Output length
271 /// equals input length (Scan emits each entry; Reduce uses only the
272 /// last). The starting `acc` is owned by Core; the binding must NOT
273 /// release it.
274 fn fold_each(
275 &self,
276 _fn_id: FnId,
277 _acc: HandleId,
278 _inputs: &[HandleId],
279 ) -> SmallVec<[HandleId; 1]> {
280 unimplemented!("fold_each: this binding does not support operators (D009)")
281 }
282
283 /// `OperatorOp::Pairwise` — pack `(prev, current)` into a tuple value.
284 /// Called once per pair (Core iterates and updates `prev` between
285 /// calls). Returns the binding-side handle for the new tuple value.
286 fn pairwise_pack(&self, _fn_id: FnId, _prev: HandleId, _current: HandleId) -> HandleId {
287 unimplemented!("pairwise_pack: this binding does not support operators (D009)")
288 }
289
290 /// `OperatorOp::Combine` / `OperatorOp::WithLatestFrom` — pack N
291 /// handles into a single tuple/array handle (Slice C-2, D020). The
292 /// binding dereferences each input handle to `T`, constructs a tuple
293 /// value (e.g., `[T0, T1, ..., Tn]`), and returns the new handle.
294 /// The returned handle has a pre-bumped retain (caller takes ownership
295 /// without additional `retain_handle` call).
296 fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId {
297 unimplemented!("pack_tuple: this binding does not support combinator operators (D020)")
298 }
299
300 // -----------------------------------------------------------------
301 // Producer lifecycle (Slice D, D031, D035). Producers are nodes
302 // with no deps + a fn — fn fires once on first subscribe and may
303 // call `Core::subscribe` from inside its body to wire up upstream
304 // sources (the zip / concat / race / takeUntil pattern).
305 //
306 // The binding maintains its own per-producer state (subscription
307 // handles, captured closure state) outside Core. When the LAST
308 // subscriber unsubscribes from a producer, Core invokes
309 // `producer_deactivate(node_id)` so the binding can drop that
310 // state — which transitively drops the producer's upstream
311 // subscriptions via `Subscription::Drop`.
312 //
313 // The hook fires lock-released (after the state lock is dropped),
314 // so the binding's deactivation impl may re-enter Core if needed
315 // (e.g., calling `release_handle` on captured handle shares).
316 //
317 // Symmetric with FnCtx: Core hands the binding a lifecycle signal
318 // and lets the binding shape its ergonomic surface (the
319 // `ProducerCtx` helper in `graphrefly-operators::producer` is one
320 // such shape; bindings may roll their own).
321 // -----------------------------------------------------------------
322
323 /// Called when a producer node loses its last subscriber. The binding
324 /// should drop any per-node state for `node_id` — captured closures,
325 /// `Vec<Subscription>` to upstream sources, etc. Default no-op for
326 /// bindings that don't ship producers.
327 ///
328 /// Fires lock-released; re-entrance into Core (e.g., `release_handle`
329 /// on captured handle shares, or even `subscribe` for re-activation
330 /// scenarios — though the latter is unusual) is permitted.
331 fn producer_deactivate(&self, _node_id: NodeId) {}
332
333 /// R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused
334 /// node's pause buffer overflows the configured cap. Called once per
335 /// overflow event (the first drop of a pause cycle); the returned
336 /// handle becomes the payload of `Message::Error` that Core then
337 /// emits via the standard terminal cascade.
338 ///
339 /// Bindings that ship their own diagnostic shape (e.g. structured
340 /// `{ code, nodeId, dropped, configuredMax, lockHeldDurationMs }`
341 /// JSON for the JS / Python sides) override this to intern such a
342 /// value and return its handle.
343 ///
344 /// **Default returns `None`** — Rust core falls back to the silent
345 /// drop + `ResumeReport.dropped` path. This preserves backward
346 /// compatibility for bindings that haven't yet wired up R1.3.8.c.
347 /// New bindings SHOULD implement this method to satisfy the
348 /// canonical-spec invariant.
349 ///
350 /// **Returning `None` consumes the overflow event for the cycle**
351 /// (QA A9, 2026-05-07): the per-pause-cycle `overflow_reported` flag
352 /// is set BEFORE this hook is called, so a `None` return doesn't
353 /// re-attempt synthesis on subsequent overflows in the same cycle.
354 /// If the binding is going to dynamically wire up the hook
355 /// (configuration-driven), do so before any pause cycle that may
356 /// overflow.
357 ///
358 /// Fires lock-released. The binding may re-enter Core (typical
359 /// implementation just calls `intern_value` and returns).
360 ///
361 /// `lock_held_duration_ms` is the wall-clock-monotonic duration in
362 /// milliseconds since the node first transitioned `Active → Paused`
363 /// at the start of this pause cycle; it gives consumers a sense of
364 /// how long a leaked controller held the lockset before overflow.
365 /// Sub-millisecond durations truncate to `0` (QA A8, 2026-05-07).
366 fn synthesize_pause_overflow_error(
367 &self,
368 _node_id: NodeId,
369 _dropped_count: u32,
370 _configured_max: usize,
371 _lock_held_duration_ms: u64,
372 ) -> Option<HandleId> {
373 None
374 }
375
376 // -----------------------------------------------------------------
377 // Cleanup-hook lifecycle (Slice E2 — R2.4.5 / R2.4.6 / Lock 4.A / 4.A′
378 // / Lock 6.D). Decisions: D054 (lifecycle-trigger hooks; binding owns
379 // ctx state), D055 (binding-side `Mutex<HashMap<NodeId, NodeCtxState>>`,
380 // wipe only on resubscribable terminal reset), D056 (cleanup-first
381 // before producer_deactivate), D057 (strict per-wave-per-node dedup),
382 // D058 (fire at cache-clear time), D059 (one-shot current_cleanup on
383 // OnDeactivation), D060 (binding-side panic isolation, drain
384 // iterates-don't-short-circuit), D061 (panic-discard wave drops
385 // deferred queue silently). Full design lives in
386 // `~/src/graphrefly-ts/archive/docs/SESSION-rust-port-fn-ctx-cleanup.md`.
387 //
388 // Bindings opt in by overriding `cleanup_for` and `wipe_ctx`. Default
389 // no-ops keep non-cleanup-aware bindings (e.g. minimal test bindings,
390 // bench harnesses) compiling unchanged.
391 // -----------------------------------------------------------------
392
393 /// Fire a registered user-cleanup hook for `node_id` at the lifecycle
394 /// moment indicated by `trigger`.
395 ///
396 /// # Binding-side contract
397 ///
398 /// Per D055, bindings own a `Mutex<HashMap<NodeId, NodeCtxState>>` where
399 /// `NodeCtxState = { store, current_cleanup }`. On each `invoke_fn` the
400 /// binding overwrites `current_cleanup` with whatever cleanup spec the
401 /// user fn returned. When Core later fires `cleanup_for(node, trigger)`,
402 /// the binding's impl looks up `current_cleanup.<trigger_slot>` and
403 /// invokes it if present. Absent slots are no-ops.
404 ///
405 /// **Lock discipline.** Bindings MUST release their `node_ctx` lock
406 /// before invoking the user closure (clone the closure handle out of
407 /// the map, drop the lock, fire). Holding the binding-side lock
408 /// across the user closure deadlocks if the user re-enters the
409 /// binding's high-level API from inside the cleanup.
410 ///
411 /// **Re-entrance into Core (D045 / D060).** Permitted: `release_handle`,
412 /// `Core::up(other_node, Pause/Resume/Invalidate/Teardown)`, `Core::emit`
413 /// for unrelated nodes, `Core::subscribe` / drop a `Subscription`. Not
414 /// permitted (undefined behavior): `Core::emit(self_node, ...)` from
415 /// inside `OnRerun` (creates a fresh emit during the wave's pre-fire
416 /// window); `Core::subscribe(self_node)` from inside `OnDeactivation`
417 /// (self-resubscribe race during deactivation).
418 ///
419 /// **Panic isolation (D060).** Core stays panic-naive about user code
420 /// — bindings SHOULD wrap user-closure invocations in `catch_unwind`
421 /// and surface failures via the host language's idiom (JS exception
422 /// → console.error, Python panic → warning, Rust panic → log + propagate
423 /// per binding policy). Core's deferred-drain for `OnInvalidate` wraps
424 /// each `cleanup_for` call in `catch_unwind` itself to prevent a single
425 /// panic from short-circuiting the per-wave drain (all queued cleanup
426 /// attempts run; the last panic re-raises after the drain completes).
427 /// `OnRerun` and `OnDeactivation` fire inline lock-released — a
428 /// panicking `cleanup_for` propagates out of `fire_regular` Phase 1.5
429 /// or `Subscription::Drop` respectively (Drop guarantees apply: state
430 /// lock already released).
431 ///
432 /// **Panic-discard guarantee gap (D061).** When a wave is panic-discarded
433 /// (an `invoke_fn` panics mid-wave), the queued `OnInvalidate` cleanup
434 /// hooks are dropped silently — the cascade's recorded cache-clears
435 /// never fire their cleanups. External-resource cleanup (file handles,
436 /// network sockets, external transactions) attached to `OnInvalidate`
437 /// MUST be idempotent at process exit / next successful invalidate
438 /// cycle. `OnRerun` panic-discard is moot (panic in `OnRerun` aborts
439 /// the wave's `fire_regular` before it can corrupt state). `OnDeactivation`
440 /// panic-discard is moot (Drop is invoked during stack unwinding;
441 /// double-panic during drop aborts per `std::process::abort` semantics).
442 ///
443 /// **Per-trigger lifecycle.** See [`CleanupTrigger`] for per-variant
444 /// firing rules. After each trigger fires, the binding decides whether
445 /// to clear `current_cleanup`:
446 /// - `OnRerun`: do NOT clear; the next `invoke_fn` will overwrite.
447 /// - `OnInvalidate`: do NOT clear; multiple INVALIDATEs across waves
448 /// can re-fire the same closure.
449 /// - `OnDeactivation`: clear (D059) — one-shot per activation cycle;
450 /// `store` persists separately per R2.4.6.
451 ///
452 /// Default no-op so bindings without cleanup-hook support compile
453 /// unchanged.
454 fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) {}
455
456 /// Wipe the binding-side ctx state for `node_id`.
457 ///
458 /// Called by Core ONLY on resubscribable terminal reset, per **R2.4.6**:
459 /// `ctx.store` is "wiped automatically: on resubscribable terminal
460 /// reset (when a `resubscribable: true` node hits `COMPLETE`/`ERROR`
461 /// and is later resubscribed)". Bindings drop their `NodeCtxState`
462 /// entry for `node_id`, releasing both `store` and any residual
463 /// `current_cleanup`.
464 ///
465 /// Default deactivation does NOT trigger wipe — `store` persists
466 /// across deactivation/reactivation cycles by spec design (mirrored
467 /// here to match canonical; current TS impl wipes on deactivation
468 /// per docstring at `node.ts:189-190` and is on the Phase 13.6.B
469 /// migration list per canonical-spec §11 item 3).
470 ///
471 /// Fires lock-released. Re-entrance into Core is permitted; typical
472 /// implementations just call `node_ctx.lock().remove(&node_id)`.
473 /// Default no-op.
474 fn wipe_ctx(&self, _node_id: NodeId) {}
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::handle::{FnId, HandleId, NodeId};
481 use std::sync::atomic::{AtomicU64, Ordering};
482
483 /// Test double mirroring `bindings.ts` `BindingBoundary` test patterns —
484 /// counts FFI crossings per method to verify the cleaving plane's
485 /// "zero FFI on identity-equals path" claim experimentally.
486 #[allow(clippy::struct_field_names)]
487 struct TestBinding {
488 invoke_count: AtomicU64,
489 equals_count: AtomicU64,
490 release_count: AtomicU64,
491 }
492
493 impl TestBinding {
494 fn new() -> Self {
495 Self {
496 invoke_count: AtomicU64::new(0),
497 equals_count: AtomicU64::new(0),
498 release_count: AtomicU64::new(0),
499 }
500 }
501 }
502
503 impl BindingBoundary for TestBinding {
504 fn invoke_fn(&self, _node_id: NodeId, _fn_id: FnId, dep_data: &[DepBatch]) -> FnResult {
505 self.invoke_count.fetch_add(1, Ordering::SeqCst);
506 // Echo first dep's latest handle as result; not realistic but exercises the path.
507 let handle = dep_data.first().map_or(HandleId::new(99), DepBatch::latest);
508 FnResult::Data {
509 handle,
510 tracked: None,
511 }
512 }
513
514 fn custom_equals(&self, _equals_handle: FnId, a: HandleId, b: HandleId) -> bool {
515 self.equals_count.fetch_add(1, Ordering::SeqCst);
516 a == b
517 }
518
519 fn release_handle(&self, _handle: HandleId) {
520 self.release_count.fetch_add(1, Ordering::SeqCst);
521 }
522 }
523
524 #[test]
525 fn boundary_calls_route_correctly() {
526 let b = TestBinding::new();
527 let dep = DepBatch {
528 data: smallvec::smallvec![HandleId::new(7)],
529 prev_data: NO_HANDLE,
530 involved: true,
531 };
532 let result = b.invoke_fn(NodeId::new(1), FnId::new(2), &[dep]);
533 match result {
534 FnResult::Data { handle, .. } => assert_eq!(handle, HandleId::new(7)),
535 FnResult::Noop { .. } | FnResult::Batch { .. } => panic!("expected Data variant"),
536 }
537 assert!(b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(7)));
538 assert!(!b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(8)));
539 b.release_handle(HandleId::new(7));
540
541 assert_eq!(b.invoke_count.load(Ordering::SeqCst), 1);
542 assert_eq!(b.equals_count.load(Ordering::SeqCst), 2);
543 assert_eq!(b.release_count.load(Ordering::SeqCst), 1);
544 }
545
546 #[test]
547 fn binding_is_send_and_sync() {
548 // Compile-time check: BindingBoundary impls must be Send + Sync.
549 // If TestBinding ever gains a !Send field, this fails to compile.
550 fn assert_send_sync<T: Send + Sync>() {}
551 // dyn-trait variant is the production shape (Core holds Arc<dyn BindingBoundary>).
552 fn assert_dyn_send_sync<T: ?Sized + Send + Sync>() {}
553 assert_send_sync::<TestBinding>();
554 assert_dyn_send_sync::<dyn BindingBoundary>();
555 }
556}