From 5e388cb38fe76baeaf48847b813e4b6104ae643c Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Tue, 8 Sep 2026 13:15:06 +0000 Subject: [PATCH] Assert the deep element glue is registered before the executor evaluates --- .../interpreted-executor/src/dynamic_executor.rs | 16 ++++++++++++++++ .../libraries/core-types/src/record/layout.rs | 7 +++++-- .../libraries/core-types/src/record/mod.rs | 2 +- .../libraries/core-types/src/record/owned.rs | 7 +++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 553506c1cf..f6c1460c75 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -33,6 +33,20 @@ fn over_budget(used: usize, budget: usize) -> bool { used >= budget / 8 * 7 } +/// The deep element glue is filled by runtime registration: ctors at load +/// natively, host-invoked exports on wasm. A lifetime-carrying element whose +/// glue never registered takes the shallow clone-out path, which brands its +/// arena borrows `'static` for a memo to hold across a reset, so a missed +/// registration is caught here rather than at the first capture. +fn assert_deep_element_glue() { + for (name, registered) in [ + ("graphic", core_types::record::has_deep_element_glue(std::any::TypeId::of::>())), + ("artboard", core_types::record::has_deep_element_glue(std::any::TypeId::of::>())), + ] { + assert!(registered, "deep element glue for `{name}` is not registered; the host must run `__node_registry_deep_element_{name}` before evaluating"); + } +} + fn new_arena(capacity: usize) -> Arena { Arena::new(capacity).unwrap_or_else(|| { log::error!("arena generations exhausted; continuing without frame caching"); @@ -66,6 +80,7 @@ fn noop_runtime() -> Arc { impl Default for DynamicExecutor { fn default() -> Self { + assert_deep_element_glue(); Self { output: Default::default(), tree: Default::default(), @@ -96,6 +111,7 @@ pub struct ResolvedDocumentNodeTypesDelta { impl DynamicExecutor { pub fn new(mut proto_network: ProtoNetwork) -> Result { + assert_deep_element_glue(); let mut typing_context = TypingContext::new(&node_registry::NODE_REGISTRY); typing_context.update(&mut proto_network)?; let output = proto_network.output; diff --git a/node-graph/libraries/core-types/src/record/layout.rs b/node-graph/libraries/core-types/src/record/layout.rs index 67edd1c973..9ab27042cd 100644 --- a/node-graph/libraries/core-types/src/record/layout.rs +++ b/node-graph/libraries/core-types/src/record/layout.rs @@ -515,8 +515,11 @@ where if let Some(deep) = deep_element_glue(std::any::TypeId::of::()) { return unsafe { (deep.clone_out)(ptr) }; } - // SAFETY: a lifetime-carrying element type registers deep glue, so - // this shallow path only erases borrow-free values. + // SAFETY: a lifetime-carrying element type registers deep glue, so this + // shallow path only erases borrow-free values. The registration is a + // whole-program convention rather than something this call can check: + // the executor asserts the in-tree types registered before it evaluates, + // which is where a missed wasm registration export is caught. Box::new(unsafe { erase_static(read_element::(Rec::new(ptr))) }) } unsafe fn repark(value: &(dyn std::any::Any + Send + Sync), dst: *mut u8, arena: &crate::arena::Arena) -> Option<()> diff --git a/node-graph/libraries/core-types/src/record/mod.rs b/node-graph/libraries/core-types/src/record/mod.rs index 80c7060665..6ee3fe0a14 100644 --- a/node-graph/libraries/core-types/src/record/mod.rs +++ b/node-graph/libraries/core-types/src/record/mod.rs @@ -28,7 +28,7 @@ pub use layout::{ ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, RecordLayout, copy_plan, element_dims, element_parked, element_write, element_write_hashed, empty_layout, }; -pub use owned::{OwnedRecord, deepen_field_value, register_deep_element_clone, register_deep_field_value, replay_field_value}; +pub use owned::{OwnedRecord, deepen_field_value, has_deep_element_glue, register_deep_element_clone, register_deep_field_value, replay_field_value}; pub use promote::{Promotion, assert_promoted, register_element_promote, register_field_promote, register_retained_heap}; pub use route::{RecordSource, SourcePlan}; pub use run::{Group, GroupItem, RunBuilder, RunColumn, RunView}; diff --git a/node-graph/libraries/core-types/src/record/owned.rs b/node-graph/libraries/core-types/src/record/owned.rs index 1147535f24..68614017f2 100644 --- a/node-graph/libraries/core-types/src/record/owned.rs +++ b/node-graph/libraries/core-types/src/record/owned.rs @@ -32,6 +32,13 @@ pub(in crate::record) fn deep_element_glue(type_id: std::any::TypeId) -> Option< DEEP_ELEMENT_CLONES.lock().unwrap().get(&type_id).copied() } +/// Whether elements of a type registered deep glue. The shallow clone path is +/// only sound for types that did not need to, so a host that drives the +/// registration itself checks the types it owes before it evaluates anything. +pub fn has_deep_element_glue(type_id: std::any::TypeId) -> bool { + DEEP_ELEMENT_CLONES.lock().unwrap().contains_key(&type_id) +} + /// Deep-copy overrides for field values whose content borrows the /// evaluation's arena (a graphic list holding native groups), keyed by the /// field's owned value form. Consulted at the persistence seams only: