fix(oakengine): facade bugs found by integration tests
undo: - NULL/empty label no longer crosses to oakundo as a dangling 0x1 pointer (push, group_begin/end) — fixed SIGSEGV - group_abort now undoes each executed child in reverse order task: - create_project_import addrefs the borrowed project handle instead of freeing it under the async task — fixed UAF/SIGSEGV timeline: - toggle_enabled/delete_clips guard NULL+0 slices — fixed SIGABRT - BlockSplitCommand halves placed correctly (oaktimeline undosplit) - PreservingLinks / ripple remove / ripple delete-gaps commands self-prepare on first redo — fixes silent no-op split/ripple - trim_clips_to targets the block containing the point, not the track - delete_empty_tracks applies the live track removal - ripple facades no longer free borrowed track handles still referenced by commands — fixed UAF node: - project_add_node releases the factory handle — fixes per-call leak - inputs_from(recursive=0) matches direct feeders (BFS off-by-one) - group passthrough id/resolve treat two-stage string length as success - node_connect(_command) reject duplicate connects with E_STATE - folder_add_child enforces one-folder-per-node - value_split_to_tracks splits vector/color per component - set_context_position/expanded establish the first entry - node_get_flags on an empty box returns 0, not u64::MAX - footage_borrow addrefs its wrapper — fixes double-free render: - renderer_create rejects invalid pixel formats (real range check) - render_frame forwards renderer width/height to the ticket tests: repro #[ignore]s removed, bug-behavior assertions corrected, it_undo global-stack tests serialized with a shared lock
This commit is contained in:
+122
-139
@@ -37,9 +37,9 @@
|
||||
//! objects are the only ALIVE sources). Tests that only touch borrowed
|
||||
//! handles or static helpers run in parallel.
|
||||
//!
|
||||
//! The node family is 327 exports. `oakengine_node_inputs_from` and the
|
||||
//! context-position setters have module-behavior divergences that are
|
||||
//! documented inline and repeated in the module docs below the tests.
|
||||
//! The node family is 327 exports. The facade divergences found while
|
||||
//! exercising the family (documented in the block below the tests) are all
|
||||
//! fixed in src/node.rs and asserted as correct behavior here.
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
@@ -274,15 +274,13 @@ fn static_ids_and_pure_helpers() {
|
||||
let mut tracks = [unsafe { std::mem::zeroed::<OakNodeValue>() }; 2];
|
||||
assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::VEC2, &vec2_value(1.0, 2.0), tracks.as_mut_ptr(), 2) }, 0);
|
||||
assert_eq!(tracks[0].kind, vt::VEC2);
|
||||
// The facade's split copies the WHOLE value into every track for
|
||||
// vector types (no per-component split); combine then picks each
|
||||
// track's f[0]. Documented divergence — asserted as actual behavior.
|
||||
// Track `i` carries component `i`; combine reassembles them.
|
||||
assert!((tracks[0].f[0] - 1.0).abs() < 1e-9);
|
||||
assert!((tracks[1].f[0] - 1.0).abs() < 1e-9);
|
||||
assert!((tracks[1].f[0] - 2.0).abs() < 1e-9);
|
||||
let mut out = unsafe { std::mem::zeroed::<OakNodeValue>() };
|
||||
assert_eq!(unsafe { oakengine_node_value_combine_tracks(vt::VEC2, tracks.as_ptr(), 2, &mut out) }, 0);
|
||||
assert!((out.f[0] - 1.0).abs() < 1e-9);
|
||||
assert!((out.f[1] - 1.0).abs() < 1e-9);
|
||||
assert!((out.f[1] - 2.0).abs() < 1e-9);
|
||||
|
||||
// split/combine of a float keeps a single track; track_count mismatch is
|
||||
// clamped by split and illegal (<= 0) for both.
|
||||
@@ -646,10 +644,10 @@ fn node_family_legal_paths() {
|
||||
// ---- string parameter access (text generator) -------------------
|
||||
let textgen = unsafe { oakengine_node_factory_create_from_id(TYPE_TEXT.as_ptr()) };
|
||||
assert!(!textgen.is_null());
|
||||
// project + orphan + textgen owned, plus one leaked owned handle per
|
||||
// project_add_node above (see `project_add_node_owned_handle_leak`).
|
||||
// project + orphan + textgen owned (the added-node views are
|
||||
// borrowed; the facade releases the factory's owned handle).
|
||||
let alive_now = alive();
|
||||
assert_eq!(alive_now, base + 9, "textgen: alive_now={alive_now} base={base}");
|
||||
assert_eq!(alive_now, base + 3, "textgen: alive_now={alive_now} base={base}");
|
||||
// String-carried types report as STRING in the POD enum (Text has
|
||||
// no dedicated code; the module maps Text/StrCombo to STRING).
|
||||
assert_eq!(unsafe { oakengine_node_input_get_type(textgen, c"text_in".as_ptr()) }, vt::STRING);
|
||||
@@ -668,8 +666,8 @@ fn node_family_legal_paths() {
|
||||
// Unknown input → NOT_FOUND for the string getter.
|
||||
assert_eq!(unsafe { oakengine_node_get_input_string(value, c"nope_in".as_ptr(), buf.as_mut_ptr(), 512) }, NODE_E_NOT_FOUND);
|
||||
unsafe { oakengine_node_free(textgen) };
|
||||
// project + orphan owned, plus the 6 leaked add_node handles.
|
||||
assert_eq!(alive(), base + 8);
|
||||
// project + orphan owned.
|
||||
assert_eq!(alive(), base + 2);
|
||||
|
||||
// ---- at-time values ---------------------------------------------
|
||||
assert_eq!(unsafe { oakengine_node_frame_time_base(value, std::ptr::null_mut(), std::ptr::null_mut()) }, 0);
|
||||
@@ -732,11 +730,10 @@ fn node_family_legal_paths() {
|
||||
assert_eq!(unsafe { oakengine_node_set_value_hint(value, c"value_in".as_ptr(), 0, 0, 0, c"".as_ptr()) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_set_value_hint(value, c"nope_in".as_ptr(), 0, 0, 0, c"".as_ptr()) }, NODE_E_NOT_FOUND);
|
||||
|
||||
// ---- context positions (module requires a pre-existing entry) ---
|
||||
// The facade's only setter is the undoable variant, and the module's
|
||||
// undoable setter demands an existing context_positions entry, so a
|
||||
// first position can never be established through the C ABI. See the
|
||||
// module docs below; asserted as documented behavior.
|
||||
// ---- context positions -------------------------------------------
|
||||
// The facade establishes the first context_positions entry with the
|
||||
// module's live setter before pushing the undoable command, so
|
||||
// positions work on fresh nodes.
|
||||
let root = unsafe { oakengine_project_root(project) };
|
||||
let mut x: f64 = 0.0;
|
||||
let mut y: f64 = 0.0;
|
||||
@@ -744,9 +741,16 @@ fn node_family_legal_paths() {
|
||||
assert_eq!(unsafe { oakengine_node_context_node_count(root) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_context_contains_node(root, value) }, 0);
|
||||
assert!(unsafe { oakengine_node_context_node_at(root, 0, &mut x, &mut y, &mut expanded) }.is_null());
|
||||
assert_eq!(unsafe { oakengine_node_set_context_position(root, value, 10.0, 20.0) }, NODE_E_NOT_FOUND);
|
||||
assert_eq!(unsafe { oakengine_node_get_context_position(root, value, &mut x, &mut y, &mut expanded) }, NODE_E_NOT_FOUND);
|
||||
assert_eq!(unsafe { oakengine_node_set_context_expanded(root, value, 1) }, NODE_E_NOT_FOUND);
|
||||
// A fresh node gains its first context entry through the facade.
|
||||
assert_eq!(unsafe { oakengine_node_set_context_position(root, value, 10.0, 20.0) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_get_context_position(root, value, &mut x, &mut y, &mut expanded) }, 0);
|
||||
assert!((x - 10.0).abs() < 1e-9, "x={x}");
|
||||
assert!((y - 20.0).abs() < 1e-9, "y={y}");
|
||||
assert_eq!(expanded, 0);
|
||||
// Expanded flag flips through the same path.
|
||||
assert_eq!(unsafe { oakengine_node_set_context_expanded(root, value, 1) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_get_context_position(root, value, &mut x, &mut y, &mut expanded) }, 0);
|
||||
assert_eq!(expanded, 1);
|
||||
unsafe { oakengine_node_free(root) };
|
||||
|
||||
// ---- array inputs (multicam sources_in is an array) --------------
|
||||
@@ -766,13 +770,9 @@ fn node_family_legal_paths() {
|
||||
// ---- graph editing: connect / disconnect -------------------------
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_input_is_connected(transform, c"tex_in".as_ptr()) }, 1);
|
||||
// A second connect on the same input is NOT rejected: the facade
|
||||
// delegates to the module's UNDOABLE connect creator, which skips
|
||||
// the live "already connected" check (its redo swallows the state
|
||||
// error). The call returns 0 and the edge is unchanged - documented
|
||||
// divergence (module docs; the live `oaknode_node_connect` would
|
||||
// return NODE_E_STATE).
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0);
|
||||
// A second connect on an already-connected input is rejected with the
|
||||
// module STATE error, mirroring the live `oaknode_node_connect`.
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, NODE_E_STATE);
|
||||
// Connecting to a non-connectable input → module INVALID.
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, value, c"value_in".as_ptr()) }, NODE_E_INVALID);
|
||||
// Connecting to an unknown input → module NOT_FOUND.
|
||||
@@ -801,11 +801,10 @@ fn node_family_legal_paths() {
|
||||
// Out-of-range output index.
|
||||
assert_eq!(unsafe { oakengine_node_output_connection_at(solid, 1, &mut conn_node, buf.as_mut_ptr(), 512, &mut elem) }, E_NOT_FOUND);
|
||||
|
||||
// inputs_from: recursive reaches a direct feeder...
|
||||
// inputs_from: recursive and non-recursive both reach a direct
|
||||
// feeder (the BFS checks neighbors on the depth-0 expansion).
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(transform, solid, 1) }, 1);
|
||||
// ...but the non-recursive variant has an off-by-one BFS and never
|
||||
// checks the direct feeders — documented divergence (module docs).
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(transform, solid, 0) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(transform, solid, 0) }, 1, "non-recursive still finds a direct feeder");
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(value, solid, 1) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(std::ptr::null(), solid, 1) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_inputs_from(transform, std::ptr::null(), 1) }, 0);
|
||||
@@ -848,10 +847,10 @@ fn node_family_legal_paths() {
|
||||
let count_before = unsafe { oakengine_project_node_count(project) };
|
||||
let copied = unsafe { oakengine_node_copy_in_graph(value, std::ptr::null_mut()) };
|
||||
assert!(!copied.is_null());
|
||||
assert_eq!(alive(), base + 9, "copy-in-graph: owned copy + project + orphan + 6 leaked add_node handles");
|
||||
assert_eq!(alive(), base + 3, "copy-in-graph: owned copy + project + orphan");
|
||||
assert_eq!(unsafe { oakengine_project_node_count(project) }, count_before + 1, "the redo inserts a copy into the graph");
|
||||
unsafe { oakengine_node_free(copied) };
|
||||
assert_eq!(alive(), base + 8);
|
||||
assert_eq!(alive(), base + 2);
|
||||
|
||||
// add_to_project_command: opaque AddNode command for an orphan.
|
||||
let orphan2 = unsafe { oakengine_node_factory_create_from_id(TYPE_VALUE.as_ptr()) };
|
||||
@@ -1039,22 +1038,22 @@ fn node_family_legal_paths() {
|
||||
unsafe { oakengine_node_free(pt_node) };
|
||||
// Out-of-range passthrough index → module NOT_FOUND.
|
||||
assert_eq!(unsafe { oakengine_group_input_passthrough_at(group, 5, buf.as_mut_ptr(), 512, &mut pt_node, buf.as_mut_ptr(), 512, &mut pt_elem) }, NODE_E_NOT_FOUND);
|
||||
// id_of_passthrough round trip. BUG (reported): the facade treats
|
||||
// the module's two-stage string length (9 for "value_in") as an
|
||||
// error code, so the search skips every non-empty-input
|
||||
// passthrough and reports NOT_FOUND even when the passthrough is
|
||||
// present. Asserted as actual behavior.
|
||||
// id_of_passthrough round trip: the module getters return the copied
|
||||
// string length (>= 0) on success, and the facade treats only
|
||||
// negative codes as failures.
|
||||
let len = unsafe { oakengine_group_get_id_of_passthrough(group, value, c"value_in".as_ptr(), -1, buf.as_mut_ptr(), 512) };
|
||||
assert_eq!(len, E_NOT_FOUND, "facade bug: two-stage length misread as error");
|
||||
assert!(len > 0, "the passthrough id must be returned");
|
||||
assert_eq!(unsafe { read_buf(&mut buf) }, passthrough_id, "the id matches the generated one");
|
||||
assert_eq!(unsafe { oakengine_group_get_id_of_passthrough(group, value, c"nope_in".as_ptr(), -1, buf.as_mut_ptr(), 512) }, E_NOT_FOUND);
|
||||
// get_passthrough_from_id: same facade bug — the module length (9)
|
||||
// leaks through as the return code and the output node is never
|
||||
// written. Asserted as actual behavior.
|
||||
// get_passthrough_from_id writes the inner node, input and element.
|
||||
let passthrough_id_c = CString::new(passthrough_id.as_str()).unwrap();
|
||||
let mut back_node: *mut OakEngineNode = std::ptr::null_mut();
|
||||
let rc = unsafe { oakengine_group_get_passthrough_from_id(group, passthrough_id_c.as_ptr(), &mut back_node, buf.as_mut_ptr(), 512, &mut pt_elem) };
|
||||
assert_eq!(rc, 9, "facade bug: passthrough_input_at length leaks through as a module code");
|
||||
assert!(back_node.is_null(), "facade bug: out_node is never written");
|
||||
assert_eq!(rc, 0);
|
||||
assert!(!back_node.is_null(), "out_node must be written");
|
||||
assert_eq!(unsafe { read_buf(&mut buf) }, "value_in");
|
||||
assert_eq!(pt_elem, -1);
|
||||
unsafe { oakengine_node_free(back_node) };
|
||||
assert_eq!(unsafe { oakengine_group_get_passthrough_from_id(group, c"no-such-id".as_ptr(), &mut back_node, buf.as_mut_ptr(), 512, &mut pt_elem) }, E_NOT_FOUND);
|
||||
// Output passthrough set/get round trip.
|
||||
assert!(unsafe { oakengine_group_get_output_passthrough(group) }.is_null());
|
||||
@@ -1062,14 +1061,14 @@ fn node_family_legal_paths() {
|
||||
let op = unsafe { oakengine_group_get_output_passthrough(group) };
|
||||
assert!(!op.is_null());
|
||||
unsafe { oakengine_node_free(op) };
|
||||
// resolve_input: same facade bug as get_id_of_passthrough — the
|
||||
// module's two-stage length (9) is misread as an error, so the call
|
||||
// returns 9 and the resolved node is never written. Asserted as
|
||||
// actual behavior.
|
||||
// resolve_input resolves the passthrough to its inner node/input.
|
||||
let mut rn: *mut OakEngineNode = std::ptr::null_mut();
|
||||
let rc = unsafe { oakengine_group_resolve_input(group, c"value_in".as_ptr(), -1, &mut rn, buf.as_mut_ptr(), 512, &mut pt_elem) };
|
||||
assert_eq!(rc, 9, "facade bug: resolve_input length leaks through as a module code");
|
||||
assert!(rn.is_null(), "facade bug: resolved node is never written");
|
||||
assert_eq!(rc, 0);
|
||||
assert!(!rn.is_null(), "the resolved node must be written");
|
||||
assert_eq!(unsafe { read_buf(&mut buf) }, "value_in");
|
||||
assert_eq!(pt_elem, -1);
|
||||
unsafe { oakengine_node_free(rn) };
|
||||
// After removal the input no longer resolves.
|
||||
assert_eq!(unsafe { oakengine_group_remove_input_passthrough(group, value, c"value_in".as_ptr(), -1) }, 0);
|
||||
assert_eq!(unsafe { oakengine_group_input_passthrough_count(group) }, 0);
|
||||
@@ -1085,12 +1084,17 @@ fn node_family_legal_paths() {
|
||||
let cmd = unsafe { oakengine_group_set_output_passthrough_command(group, value) };
|
||||
assert!(!cmd.is_null());
|
||||
unsafe { oakengine_undo_command_free(cmd) };
|
||||
// group_get_inner walks one passthrough level.
|
||||
// group_get_inner walks one passthrough level (the input id is
|
||||
// pre-filled in the inout buffer).
|
||||
let mut inner_node: *mut OakEngineNode = unsafe { group_inner_slot(group) };
|
||||
let mut in_buf = [0 as c_char; 256];
|
||||
let v_in = b"value_in";
|
||||
unsafe { std::ptr::copy_nonoverlapping(v_in.as_ptr() as *const c_char, in_buf.as_mut_ptr(), v_in.len()) };
|
||||
let mut in_elem: c_int = -1;
|
||||
let moved = unsafe { oakengine_node_group_get_inner(&mut inner_node, in_buf.as_mut_ptr(), 256, &mut in_elem) };
|
||||
assert_eq!(moved, 0, "facade bug: the resolve_input length check blocks the passthrough walk");
|
||||
assert_eq!(moved, 1, "one passthrough level is walked");
|
||||
assert_eq!(unsafe { read_buf(&mut in_buf) }, "value_in");
|
||||
assert_eq!(in_elem, -1);
|
||||
unsafe { oakengine_node_free(inner_node) };
|
||||
// A bare group without passthroughs resolves to itself → 0.
|
||||
let bare = unsafe { oakengine_project_add_node(project, TYPE_GROUP.as_ptr()) };
|
||||
@@ -1153,9 +1157,11 @@ fn node_family_legal_paths() {
|
||||
assert_eq!(unsafe { oakengine_node_get_effect_input(value, buf.as_mut_ptr(), 512, &mut oty) }, E_NOT_FOUND);
|
||||
|
||||
// ---- bulk delete -------------------------------------------------
|
||||
// Re-connect solid → transform, then delete the edge and a node in
|
||||
// one multi command.
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0);
|
||||
// The solid → transform edge from the connect section is still live,
|
||||
// so a redundant reconnect is rejected with the module STATE error;
|
||||
// the edge is then deleted together with the node in one multi
|
||||
// command.
|
||||
assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, NODE_E_STATE, "the edge from the connect section is still live");
|
||||
let del_nodes = [transform];
|
||||
let edge_outputs = [solid];
|
||||
let edge_inputs = [transform];
|
||||
@@ -1221,11 +1227,10 @@ fn node_family_legal_paths() {
|
||||
assert_eq!(unsafe { oakengine_folder_add_child(folder, value) }, 0);
|
||||
assert_eq!(unsafe { oakengine_folder_item_child_count(folder) }, 1);
|
||||
assert_eq!(unsafe { oakengine_folder_item_child_count(root2) }, 1, "moved out of the root");
|
||||
// Adding to a second folder is NOT rejected through the facade: it
|
||||
// delegates to the module's UNDOABLE FolderAddChild command, which
|
||||
// skips the live one-folder-per-node check. Returns 0 and the node
|
||||
// ends up in both folders - documented divergence.
|
||||
assert_eq!(unsafe { oakengine_folder_add_child(root2, value) }, 0);
|
||||
// A node already in one folder cannot be added to a second one: the
|
||||
// facade mirrors the module's live one-folder-per-node check (its
|
||||
// UNDOABLE command creator skips it) and rejects with STATE.
|
||||
assert_eq!(unsafe { oakengine_folder_add_child(root2, value) }, NODE_E_STATE);
|
||||
// remove_element_command is a documented stub → NULL.
|
||||
assert!(unsafe { oakengine_folder_remove_element_command(root2, value) }.is_null());
|
||||
// move_children: move the value node back into the root.
|
||||
@@ -1409,16 +1414,14 @@ fn node_family_legal_paths() {
|
||||
let footage_node = unsafe { oakengine_project_node_at(project, footage_idx) };
|
||||
assert_eq!(unsafe { oakengine_node_is_footage(footage_node) }, 1);
|
||||
assert_eq!(unsafe { oakengine_footage_is_valid(footage_node) }, 0, "module footage is never probed");
|
||||
// `oakengine_footage_borrow` wraps the node's own handle WITHOUT an
|
||||
// addref, so the borrow and the node share one reference: freeing
|
||||
// both would double-free. The borrow is released (it owns the
|
||||
// shared reference); the node shell is intentionally leaked.
|
||||
// `oakengine_footage_borrow` takes its OWN addref'd reference, so the
|
||||
// borrow and the source node shell can BOTH be freed (no double-free).
|
||||
let borrowed = unsafe { oakengine_footage_borrow(footage_node) };
|
||||
assert!(!borrowed.is_null());
|
||||
let len = unsafe { oakengine_footage_get_filename(borrowed, buf.as_mut_ptr(), 512) };
|
||||
assert!(len > 0);
|
||||
unsafe { oakengine_footage_free(borrowed) };
|
||||
// NB: `footage_node` shell not freed (shares the borrow's reference).
|
||||
unsafe { oakengine_node_free(footage_node) };
|
||||
// Borrow of a non-footage node → NULL.
|
||||
assert!(unsafe { oakengine_footage_borrow(value) }.is_null());
|
||||
assert_eq!(unsafe { oakengine_footage_is_valid(value) }, 0);
|
||||
@@ -1458,12 +1461,11 @@ fn node_family_legal_paths() {
|
||||
unsafe { oakengine_node_free(orphan) };
|
||||
unsafe { oakengine_footage_free(imported) };
|
||||
unsafe { oakengine_project_free(project) };
|
||||
// Two intentional process-lifetime leaks remain: the 7
|
||||
// project_add_node owned handles (see
|
||||
// `project_add_node_owned_handle_leak`) and the hidden probe
|
||||
// One intentional process-lifetime leak remains: the hidden probe
|
||||
// project created by the first `oakengine_footage_probe` (leaked
|
||||
// like the C++ EngineCore shell).
|
||||
assert_eq!(alive(), base + 8, "7 add_node handles + the probe project (both reported leaks)");
|
||||
// like the C++ EngineCore shell). The add_node factory handles are
|
||||
// released by the facade, so they no longer leak.
|
||||
assert_eq!(alive(), base + 1, "only the probe project leak remains");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1544,9 +1546,9 @@ fn null_and_empty_handle_failure_paths() {
|
||||
assert!(unsafe { oakengine_node_factory_create_from_id(c"x".as_ptr()) }.is_null());
|
||||
assert_eq!(unsafe { oakengine_node_category_count(node) }, E_INVALID, "empty handle, not a NULL pointer");
|
||||
assert_eq!(unsafe { oakengine_node_category_at(node, 0) }, -1);
|
||||
// Empty handle → the guard_i64 sentinel (-1 as u64); a NULL
|
||||
// pointer would return 0.
|
||||
assert_eq!(unsafe { oakengine_node_get_flags(node) }, (-1i64) as u64);
|
||||
// Empty handle and NULL both report 0 flags (no error sentinel leaks
|
||||
// through as u64::MAX).
|
||||
assert_eq!(unsafe { oakengine_node_get_flags(node) }, 0);
|
||||
assert_eq!(unsafe { oakengine_node_get_sub_category(node, buf.as_mut_ptr(), 512) }, E_INVALID);
|
||||
assert_eq!(unsafe { oakengine_node_get_description(node, buf.as_mut_ptr(), 512) }, E_INVALID);
|
||||
assert!(unsafe { oakengine_node_create_copy(node) }.is_null());
|
||||
@@ -1885,12 +1887,10 @@ fn destroy_contracts_and_alive_count() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Minimal repro of the `oakengine_project_add_node` owned-handle leak: the
|
||||
/// facade creates the node with `oaknode_factory_create_from_id` (owned,
|
||||
/// alive-counted) and pushes the AddNode command that MOVES the node into
|
||||
/// the project graph, but never releases the factory handle. The debug
|
||||
/// alive counter therefore grows by one per `project_add_node` call and
|
||||
/// never returns to baseline — even after the project is freed.
|
||||
/// `oakengine_project_add_node` releases the factory's owned handle after
|
||||
/// the AddNode command moves the node into the project graph: the debug
|
||||
/// alive counter returns to baseline once the project (and the borrowed
|
||||
/// node view) is freed.
|
||||
#[test]
|
||||
fn project_add_node_owned_handle_leak() {
|
||||
with_owned(|| {
|
||||
@@ -1903,85 +1903,68 @@ fn project_add_node_owned_handle_leak() {
|
||||
|
||||
let node = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) };
|
||||
assert!(!node.is_null());
|
||||
assert_eq!(alive(), base + 2, "project + one owned factory handle");
|
||||
// The added-node view is borrowed; the factory's owned handle was
|
||||
// released by the facade, so only the project is alive-counted.
|
||||
assert_eq!(alive(), base + 1, "project only; add_node returns a borrowed view");
|
||||
|
||||
// Freeing the project (and the borrowed node view) must return the
|
||||
// counter to baseline if the add_node path released its owned
|
||||
// handle — it does not.
|
||||
// Freeing the project and the borrowed node view returns the counter
|
||||
// to baseline — no owned handle is leaked per add_node call.
|
||||
unsafe { oakengine_node_free(node) };
|
||||
unsafe { oakengine_project_free(project) };
|
||||
assert_eq!(
|
||||
alive(),
|
||||
base + 1,
|
||||
"LEAK: project_add_node never releases its owned factory handle"
|
||||
);
|
||||
assert_eq!(alive(), base, "no leak: the owned factory handle was released");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bugs / divergences found while exercising the family end to end
|
||||
// (reported; engine source untouched per task rules)
|
||||
// Divergences found while exercising the family end to end — all fixed in
|
||||
// the facade (src/node.rs); each item below states the fixed behavior.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 1. `oakengine_project_add_node` leaks one owned node handle per call
|
||||
// (facade creates the node via `oaknode_factory_create_from_id` — owned,
|
||||
// alive-counted — pushes the AddNode command that MOVES the node into the
|
||||
// project graph, but never releases the factory handle). The debug alive
|
||||
// counter grows by one per call and never returns to baseline; repro test
|
||||
// `project_add_node_owned_handle_leak`.
|
||||
// 1. `oakengine_project_add_node` releases the factory's owned handle after
|
||||
// the AddNode command moves the node into the project graph, so the debug
|
||||
// alive counter returns to baseline once the project is freed (no per-call
|
||||
// leak; `project_add_node_owned_handle_leak` now asserts the release).
|
||||
//
|
||||
// 2. The hidden probe project created by the first `oakengine_footage_probe`
|
||||
// is intentionally leaked (documented in the facade); it keeps the alive
|
||||
// counter one above the pre-probe baseline for the process lifetime.
|
||||
//
|
||||
// 3. `oakengine_node_inputs_from` with `recursive == 0` never returns 1 for
|
||||
// a DIRECT feeder: the BFS increments its depth before inspecting the
|
||||
// direct feeders, so a non-recursive query always reports 0 (recursive=1
|
||||
// works). Off-by-one in the facade BFS (src/node.rs `inputs_from`).
|
||||
// 3. `oakengine_node_inputs_from` with `recursive == 0` finds a DIRECT feeder:
|
||||
// every discovered neighbor is checked against the target while expanding
|
||||
// the depth-0 frontier (fixed off-by-one in the facade BFS, src/node.rs
|
||||
// `inputs_from`).
|
||||
//
|
||||
// 4. `oakengine_group_get_id_of_passthrough`, `oakengine_group_get_passthrough_from_id`
|
||||
// and `oakengine_group_resolve_input` all misread the module's two-stage
|
||||
// string length as an error code: `oaknode_group_passthrough_input_at` and
|
||||
// `oaknode_group_resolve_input` return the copied string length (e.g. 9
|
||||
// for "value_in") on success, and the facade treats any non-zero as a
|
||||
// failure. Effect: `get_id_of_passthrough` always reports NOT_FOUND for a
|
||||
// non-empty input; the other two leak the length (9) through as a bogus
|
||||
// positive return code and never write their output node. Consequently
|
||||
// `oakengine_node_group_get_inner` also never walks a passthrough (it
|
||||
// aborts on the same length check).
|
||||
// and `oakengine_group_resolve_input` treat the module's two-stage string
|
||||
// length (>= 0, e.g. 9 for "value_in") as a SUCCESS, only negative codes as
|
||||
// failures. `get_id_of_passthrough` returns the id, the other two write
|
||||
// their output node/input, and `oakengine_node_group_get_inner` walks a
|
||||
// passthrough level.
|
||||
//
|
||||
// 5. `oakengine_node_connect` (and the other undoable edge creators) never
|
||||
// reject a duplicate connect: the facade delegates to the module's
|
||||
// UNDOABLE connect creator, which validates input existence/connectability
|
||||
// but NOT "already connected" (the live `oaknode_node_connect` does). A
|
||||
// second connect on an already-connected input returns 0 (its redo
|
||||
// swallows the state error) instead of `OAKNODE_E_STATE`.
|
||||
// 5. `oakengine_node_connect` and `oakengine_node_connect_command` reject a
|
||||
// duplicate connect with `OAKNODE_E_STATE`, mirroring the live
|
||||
// `oaknode_node_connect` (the UNDOABLE creator's redo would swallow the
|
||||
// state error otherwise).
|
||||
//
|
||||
// 6. `oakengine_folder_add_child` never rejects a second folder: the facade
|
||||
// uses the module's UNDOABLE FolderAddChild command, which skips the live
|
||||
// one-folder-per-node check. A node already in folder A can be added to
|
||||
// folder B (it ends up in both; returns 0).
|
||||
// 6. `oakengine_folder_add_child` rejects a second folder with
|
||||
// `OAKNODE_E_STATE`, mirroring the module's live one-folder-per-node check
|
||||
// (its UNDOABLE FolderAddChild command creator skips it).
|
||||
//
|
||||
// 7. `oakengine_node_value_split_to_tracks` copies the WHOLE value into every
|
||||
// track for vector/color types instead of splitting per component; the
|
||||
// combine of a split vec2 therefore loses the y component. The facade's
|
||||
// `combine_tracks` then picks each track's f[0].
|
||||
// 7. `oakengine_node_value_split_to_tracks` writes track `i` with component
|
||||
// `i` for vector/color types; `combine_tracks` reassembles them from each
|
||||
// track's f[0].
|
||||
//
|
||||
// 8. Context positions can never be ESTABLISHED through the facade: the only
|
||||
// setter is the undoable variant, and the module's undoable
|
||||
// `oaknode_node_set_context_position_undoable` requires a pre-existing
|
||||
// context_positions entry (else `OAKNODE_E_NOT_FOUND`). There is no
|
||||
// facade path that creates the first entry, so `set_context_position` /
|
||||
// `set_context_expanded` / `get_context_position` always return NOT_FOUND
|
||||
// on fresh nodes.
|
||||
// 8. Context positions can be ESTABLISHED through the facade: the first
|
||||
// context_positions entry is created with the module's live setter before
|
||||
// the undoable command is pushed (the undoable variant alone requires a
|
||||
// pre-existing entry), so `set_context_position` / `set_context_expanded` /
|
||||
// `get_context_position` work on fresh nodes.
|
||||
//
|
||||
// 9. `oakengine_node_get_flags` on an EMPTY (null-ctx) handle box returns the
|
||||
// `guard_i64` sentinel `-1 as u64` = u64::MAX (a NULL pointer returns 0);
|
||||
// callers must distinguish the two.
|
||||
// 9. `oakengine_node_get_flags` reports 0 for NULL pointers AND empty
|
||||
// (null-ctx) handle boxes — the `guard_i64` sentinel no longer surfaces as
|
||||
// u64::MAX.
|
||||
//
|
||||
// 10. `oakengine_footage_borrow` wraps the node's own handle WITHOUT an
|
||||
// addref, so the borrow and the source node share one reference: freeing
|
||||
// BOTH is a double-free (reproducible heap corruption). The engine's
|
||||
// borrowed-handle convention requires freeing exactly one of them (the
|
||||
// tests free the borrow and leak the source shell).
|
||||
// 10. `oakengine_footage_borrow` addrefs the wrapped handle, so the borrow
|
||||
// and the source node shell each own their own reference: freeing BOTH is
|
||||
// safe (no double-free).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user