Skip to content

Commit 139ebb9

Browse files
Merge pull request #264 from mitchmindtree/feat/nodeui-changed-responses
feat: Reliable per-head "changed" signal, i.e. stop re-hashing graphs every frame
2 parents afa35b6 + 3e63209 commit 139ebb9

28 files changed

Lines changed: 899 additions & 330 deletions

crates/bevy_gantz/src/head.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ pub struct OpenHead;
4949
pub struct HeadRef(pub ca::Head);
5050

5151
/// The working copy of the graph associated with this head.
52+
///
53+
/// # Invariant: commit before returning
54+
///
55+
/// Any system that mutates a head's `WorkingGraph` MUST commit it before the
56+
/// system returns - either via [`vm::commit_working_graph`](crate::vm::commit_working_graph)
57+
/// (in-place edits: the GUI pass and graph ops) or by replacing the working
58+
/// graph with a registry commit's graph and resetting
59+
/// [`CompiledInputs`](crate::vm::CompiledInputs) (head open/replace/branch-move
60+
/// and resync already do this). Consequently, *between* systems the working
61+
/// graph's content address always equals the head's committed graph CA
62+
/// (`registry.head_commit(head).graph`).
63+
///
64+
/// [`vm::sync`](crate::vm::sync) relies on this: it reads the committed CA
65+
/// directly to decide when to recompile and never re-hashes the working graph
66+
/// (see #159). [`vm::validate_committed`](crate::vm::validate_committed) is a
67+
/// default-off debug check that flags any violation of this invariant.
5268
#[derive(Component)]
5369
pub struct WorkingGraph<N>(pub gantz_core::node::graph::Graph<N>);
5470

crates/bevy_gantz/src/lib.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ pub use head::{
3737
WorkingGraph,
3838
};
3939
pub use reg::{Registry, lookup_node, timestamp};
40-
pub use vm::{CompileConfig, CompiledInputs, EntrypointFns, EvalEntryComplete, EvalEntryEvent};
40+
pub use vm::{
41+
CompileConfig, CompiledInputs, EntrypointFns, EvalEntryComplete, EvalEntryEvent,
42+
ValidateCommitted, commit_working_graph,
43+
};
4144

4245
/// The system set in which [`vm::sync`] runs (in the `Update` schedule).
4346
///
@@ -80,6 +83,7 @@ where
8083
.init_resource::<HeadTabOrder>()
8184
.init_resource::<Registry<N>>()
8285
.init_resource::<vm::CompileConfig>()
86+
.init_resource::<vm::ValidateCommitted>()
8387
.init_non_send::<HeadVms>()
8488
// Register head event handlers.
8589
.add_observer(head::on_open::<N>)
@@ -90,8 +94,11 @@ where
9094
// Register eval entry event handler.
9195
.add_observer(vm::on_eval_entry)
9296
// Input-addressed VM synchronisation: (re)compiles whenever a head's
93-
// compile inputs (graph content address + config) change.
94-
.add_systems(Update, vm::sync::<N>.in_set(VmSet));
97+
// compile inputs (committed graph content address + config) change.
98+
.add_systems(Update, vm::sync::<N>.in_set(VmSet))
99+
// Debug check for the WorkingGraph commit-before-return invariant
100+
// (no-op unless `ValidateCommitted` is enabled).
101+
.add_systems(Update, vm::validate_committed::<N>.after(VmSet));
95102
}
96103
}
97104

crates/bevy_gantz/src/vm.rs

Lines changed: 107 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,22 @@ struct Inputs {
9292

9393
/// The inputs of a head's last compile *attempt* (success or failure).
9494
///
95-
/// `None` = never attempted. [`sync`] compares this against the current
96-
/// inputs to decide when to (re)compile - there is no dirty flag to set or
97-
/// forget.
95+
/// `None` = never attempted. [`sync`] compares this against the current inputs
96+
/// (the head's committed graph CA + config) to decide when to (re)compile -
97+
/// there is no dirty flag to set or forget.
9898
#[derive(Component, Default)]
9999
pub struct CompiledInputs(Option<Inputs>);
100100

101+
/// When `true`, [`validate_committed`] hashes every open head's working graph
102+
/// each frame and warns if it differs from the head's committed graph CA - i.e.
103+
/// a system mutated the working graph without committing it, violating the
104+
/// [`WorkingGraph`](head::WorkingGraph) commit-before-return invariant.
105+
///
106+
/// Defaults to `false` (no extra hashing); enable at runtime to debug a
107+
/// suspected missing commit.
108+
#[derive(Default, Resource)]
109+
pub struct ValidateCommitted(pub bool);
110+
101111
/// Event to trigger evaluation of an entrypoint.
102112
#[derive(Event)]
103113
pub struct EvalEntryEvent {
@@ -195,27 +205,67 @@ pub fn on_eval_entry(
195205
// Systems
196206
// ---------------------------------------------------------------------------
197207

208+
/// Commit a head's working graph to the registry when it has diverged from the
209+
/// head's current commit, updating the head and emitting a
210+
/// [`head::CommittedEvent`]. Returns `true` if a new commit was made.
211+
///
212+
/// **Call this from any system that mutates a head's
213+
/// [`WorkingGraph`](head::WorkingGraph), before the system returns** - it is how
214+
/// the commit-before-return invariant (see `WorkingGraph`) is upheld, which in
215+
/// turn lets [`sync`] recompile from the committed address without re-hashing.
216+
/// This is the single place a working graph is content-addressed.
217+
pub fn commit_working_graph<N>(
218+
registry: &mut Registry<N>,
219+
cmds: &mut Commands,
220+
entity: Entity,
221+
head: &mut ca::Head,
222+
graph: &Graph<N>,
223+
) -> bool
224+
where
225+
N: Clone + ca::CaHash,
226+
{
227+
let graph_ca = ca::graph_addr(graph);
228+
let Some(head_commit) = registry.head_commit(head) else {
229+
return false;
230+
};
231+
if head_commit.graph == graph_ca {
232+
return false;
233+
}
234+
let old_head = head.clone();
235+
let new_commit_ca = registry.commit_graph_to_head(
236+
crate::reg::timestamp(),
237+
graph_ca,
238+
|| crate::clone_graph(graph),
239+
head,
240+
);
241+
log::debug!("Graph changed -> {}", new_commit_ca.display_short());
242+
cmds.trigger(head::CommittedEvent {
243+
entity,
244+
old_head,
245+
new_head: head.clone(),
246+
});
247+
true
248+
}
249+
198250
/// Keep every open head's VM in sync with the inputs to compilation.
199251
///
200-
/// The inputs are the working graph's content address and the
201-
/// [`CompileConfig`]; each head's [`CompiledInputs`] memoizes the inputs of
202-
/// its last compile attempt, and the VM is rebuilt whenever they differ -
203-
/// there is no dirty flag to set or forget. This single rule covers head
204-
/// open (and startup spawn), head replace/branch-move, graph edits, and
205-
/// config changes.
252+
/// The inputs are the head's *committed* graph content address and the
253+
/// [`CompileConfig`]; each head's [`CompiledInputs`] memoizes the inputs of its
254+
/// last compile attempt, and the VM is rebuilt whenever they differ. The
255+
/// committed CA is read straight from the registry - **no per-frame hashing**
256+
/// (#159): the [`WorkingGraph`](head::WorkingGraph) commit-before-return
257+
/// invariant guarantees the working graph already matches it, and every change
258+
/// is reflected either by a new commit (edits, via [`commit_working_graph`]) or
259+
/// by a reset `CompiledInputs` (head open/replace/branch-move/resync), so
260+
/// comparing committed CA + config is sufficient to drive recompiles.
206261
///
207-
/// Whether the rebuild is a fresh `init` or an in-place `compile` is decided
208-
/// by VM presence in [`head::HeadVms`]: absent means a fresh init (head
262+
/// Whether the rebuild is a fresh `init` or an in-place `compile` is decided by
263+
/// VM presence in [`head::HeadVms`]: absent means a fresh init (head
209264
/// replace/branch-move remove the VM to discard the old graph's node state);
210265
/// present means an in-place compile, preserving node state (graph edits and
211266
/// config changes).
212-
///
213-
/// When the working graph's address has diverged from the head's commit, the
214-
/// graph is committed to the registry first and a [`head::CommittedEvent`]
215-
/// is emitted for UI state updates (handled by `GantzEguiPlugin` if present).
216267
pub fn sync<N>(
217-
mut cmds: Commands,
218-
mut registry: ResMut<Registry<N>>,
268+
registry: Res<Registry<N>>,
219269
builtins: Res<BuiltinNodes<N>>,
220270
ep_fns: Res<EntrypointFns<N>>,
221271
config: Res<CompileConfig>,
@@ -225,45 +275,25 @@ pub fn sync<N>(
225275
N: 'static + Node + Clone + ca::CaHash + Send + Sync,
226276
{
227277
for mut data in heads_query.iter_mut() {
228-
let graph: &Graph<N> = &*data.working_graph;
278+
// The committed graph CA - the working graph already matches it (the
279+
// `WorkingGraph` invariant), so there is nothing to hash here.
280+
let Some(graph_ca) = registry.head_commit(&data.head_ref.0).map(|c| c.graph) else {
281+
continue;
282+
};
229283
let inputs = Inputs {
230-
graph: ca::graph_addr(graph),
284+
graph: graph_ca,
231285
config: config.0,
232286
};
233287
if data.compiled_inputs.0 == Some(inputs) {
234288
continue;
235289
}
236290

237-
// Commit when the working copy has diverged from the head's commit.
238-
let head: &mut ca::Head = &mut *data.head_ref;
239-
if let Some(head_commit) = registry.head_commit(head) {
240-
if head_commit.graph != inputs.graph {
241-
let old_head = head.clone();
242-
let old_commit_ca = registry.head_commit_ca(head).copied().unwrap();
243-
let new_commit_ca = registry.commit_graph_to_head(
244-
crate::reg::timestamp(),
245-
inputs.graph,
246-
|| crate::clone_graph(graph),
247-
head,
248-
);
249-
log::debug!(
250-
"Graph changed: {} -> {}",
251-
old_commit_ca.display_short(),
252-
new_commit_ca.display_short()
253-
);
254-
cmds.trigger(head::CommittedEvent {
255-
entity: data.entity,
256-
old_head,
257-
new_head: head.clone(),
258-
});
259-
}
260-
}
261-
262291
// Rebuild the VM. On an in-place compile error the VM is kept (its
263292
// previous module remains evaluable) and the error surfaces via the
264293
// module/diagnostics components; a failed init leaves no VM, so eval
265294
// systems (e.g. `drive_frame_bangs`, `on_eval_entry`) skip the head
266295
// rather than driving a stale graph.
296+
let graph: &Graph<N> = &*data.working_graph;
267297
let get_node = |ca: &ca::ContentAddr| lookup_node(&registry, &**builtins, ca);
268298
let entrypoints = collect_entrypoints(&ep_fns, &get_node, graph);
269299
let result = match vms.get_mut(&data.entity) {
@@ -282,3 +312,36 @@ pub fn sync<N>(
282312
data.compiled_inputs.0 = Some(inputs);
283313
}
284314
}
315+
316+
/// Debug check for the [`WorkingGraph`](head::WorkingGraph) commit-before-return
317+
/// invariant.
318+
///
319+
/// When [`ValidateCommitted`] is enabled, hash every open head's working
320+
/// graph and warn if it differs from the head's committed graph CA - i.e. a
321+
/// system mutated the working graph without committing it. A no-op (no hashing)
322+
/// when disabled, which is the default.
323+
pub fn validate_committed<N>(
324+
validate: Res<ValidateCommitted>,
325+
registry: Res<Registry<N>>,
326+
heads: Query<head::OpenHeadDataReadOnly<N>, With<head::OpenHead>>,
327+
) where
328+
N: 'static + ca::CaHash + Send + Sync,
329+
{
330+
if !validate.0 {
331+
return;
332+
}
333+
for data in heads.iter() {
334+
let working = ca::graph_addr(&data.working_graph.0);
335+
let committed = registry.head_commit(&data.head_ref.0).map(|c| c.graph);
336+
if committed != Some(working) {
337+
log::warn!(
338+
"WorkingGraph invariant violated: head {:?} working graph ({}) does \
339+
not match its committed graph ({:?}) - a system mutated it without \
340+
committing (see `commit_working_graph`)",
341+
data.entity,
342+
working.display_short(),
343+
committed.map(|c| c.display_short().to_string()),
344+
);
345+
}
346+
}
347+
}

0 commit comments

Comments
 (0)