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:
2026-08-11 01:30:18 +08:00
parent b564e7a71f
commit aa5fcef66e
13 changed files with 664 additions and 405 deletions
+99 -10
View File
@@ -911,6 +911,22 @@ pub unsafe extern "C" fn oakengine_folder_add_child(
return Err(Error::Invalid);
}
let c = unbox(child)?;
// Mirror the module's live one-folder-per-node check (its UNDOABLE
// FolderAddChild command creator skips it): a node already in
// another folder is rejected with the module STATE error.
let parent = n::oaknode_folder_parent_of(c);
if !parent.ctx.is_null() {
let already_here =
n::oaknode_node_identity(parent) != 0
&& n::oaknode_node_identity(parent) == n::oaknode_node_identity(f);
// The borrowed parent handle's shell is released here.
if let Some(release) = parent.release {
unsafe { release(parent.ctx) };
}
if !already_here {
return Err(Error::Module(oaknode::error::OAKNODE_E_STATE));
}
}
let cmd = n::oaknode_command_create_folder_add_child(f, c);
if cmd.ctx.is_null() {
return Err(Error::Failed("folder add child command failed".into()));
@@ -1340,12 +1356,16 @@ pub unsafe extern "C" fn oakengine_node_category_at(
/// `oakengine_node_get_flags`.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_get_flags(self_: *const OakEngineNode) -> u64 {
// Stub: the oaknode module has no per-node flags export.
// Stub: the oaknode module has no per-node flags export. NULL and
// empty (null-ctx) handle boxes both report 0 — the `guard_i64`
// error sentinel would otherwise surface as u64::MAX to C callers.
crate::handle::guard_i64(|| unsafe {
if self_.is_null() {
return Ok(0);
}
let _ = unbox(self_)?;
if (*self_).handle.is_null() {
return Ok(0);
}
Ok(0)
}) as u64
}
@@ -2188,6 +2208,12 @@ pub unsafe extern "C" fn oakengine_project_add_node(
continue;
}
if is_node_type(other, &type_id_str) {
// The AddNode command MOVED the node into the project graph,
// so the factory's owned handle is now just a stale view:
// release it (the node itself stays graph-owned) so the
// debug alive counter returns to baseline.
let mut owned = node;
n::oaknode_node_free(&mut owned);
return Ok(box_handle::<OakEngineNode>(other));
}
}
@@ -2262,6 +2288,20 @@ pub unsafe extern "C" fn oakengine_node_connect(
}
let out_h = unbox(output_node)?;
let in_h = unbox(input_node)?;
// Mirror the module's live connect rejection: an already-connected
// input is a STATE error. The UNDOABLE creator validates existence
// and connectability but not "already connected" (its redo swallows
// the state error), so the facade pre-checks like the live variant.
let mut connected: c_int = 0;
Error::from_module(n::oaknode_node_input_is_connected(
in_h,
input_id,
&mut connected,
))?;
if connected != 0 {
set_node_error("input is already connected");
return Err(Error::Module(oaknode::error::OAKNODE_E_STATE));
}
let mut cmd: CHandle = CHandle::null();
let rc = n::oaknode_node_connect_undoable(out_h, in_h, input_id, &mut cmd);
if rc != 0 {
@@ -2325,6 +2365,16 @@ pub unsafe extern "C" fn oakengine_node_connect_command(
let out_h = unbox(output_node)?;
let in_h = unbox(input_node)?;
let _ = element;
// Same duplicate-connect rejection as `oakengine_node_connect`.
let mut connected: c_int = 0;
Error::from_module(n::oaknode_node_input_is_connected(
in_h,
input_id,
&mut connected,
))?;
if connected != 0 {
return Ok(std::ptr::null_mut());
}
let mut cmd: CHandle = CHandle::null();
let rc = n::oaknode_node_connect_undoable(out_h, in_h, input_id, &mut cmd);
if rc != 0 {
@@ -3588,6 +3638,16 @@ pub unsafe extern "C" fn oakengine_node_set_context_position(
}
let ch = unbox(context)?;
let nh = unbox(node)?;
// The module's undoable setter requires a pre-existing
// context_positions entry (else NOT_FOUND); create the first entry
// with the live setter so positions can be ESTABLISHED through the
// facade (bug fix: they were previously impossible to create).
let mut x0: f64 = 0.0;
let mut y0: f64 = 0.0;
let mut e0: c_int = 0;
if n::oaknode_node_get_context_position(nh, ch, &mut x0, &mut y0, &mut e0) != 0 {
Error::from_module(n::oaknode_node_set_context_position(nh, ch, 0.0, 0.0, 0))?;
}
let mut cmd: CHandle = CHandle::null();
let rc = n::oaknode_node_set_context_position_undoable(nh, ch, x, y, 0, &mut cmd);
if rc != 0 {
@@ -3642,7 +3702,9 @@ pub unsafe extern "C" fn oakengine_node_set_context_expanded(
let mut was: c_int = 0;
let rc = n::oaknode_node_get_context_position(nh, ch, &mut x, &mut y, &mut was);
if rc != 0 {
return Err(Error::Module(rc));
// No entry yet: establish one (the module's undoable setter
// below demands a pre-existing context_positions entry).
Error::from_module(n::oaknode_node_set_context_position(nh, ch, 0.0, 0.0, 0))?;
}
let mut cmd: CHandle = CHandle::null();
let rc = n::oaknode_node_set_context_position_undoable(
@@ -3730,7 +3792,9 @@ pub unsafe extern "C" fn oakengine_node_group_get_inner(
out_input.len() as c_int,
&mut out_element,
);
if rc != 0 || out_node.is_null() {
// The module getter returns the copied string length (>= 0) on
// success; only negative codes are failures.
if rc < 0 || out_node.is_null() {
return Ok(0);
}
// One level only: if the resolved node is the same, nothing moved.
@@ -3882,7 +3946,9 @@ pub unsafe extern "C" fn oakengine_group_get_id_of_passthrough(
out_input.len() as c_int,
&mut out_element,
);
if rc != 0 || out_node.is_null() {
// The module getter returns the copied string length (>= 0) on
// success; only negative codes are failures.
if rc < 0 || out_node.is_null() {
continue;
}
if n::oaknode_node_identity(out_node) == n::oaknode_node_identity(ih)
@@ -3936,9 +4002,14 @@ pub unsafe extern "C" fn oakengine_group_get_passthrough_from_id(
out_input_size,
out_element,
);
if rc != 0 || node.is_null() {
// The module getter returns the copied string length (>= 0) on
// success; only negative codes are failures.
if rc < 0 {
return Err(Error::Module(rc));
}
if node.is_null() {
continue;
}
if !out_node.is_null() {
*out_node = box_handle::<OakEngineNode>(node);
}
@@ -4016,7 +4087,9 @@ pub unsafe extern "C" fn oakengine_group_resolve_input(
out_input_size,
out_element,
);
if rc != 0 {
// The module getter returns the copied string length (>= 0) on
// success; only negative codes are failures.
if rc < 0 {
return Err(Error::Module(rc));
}
if !out_node.is_null() {
@@ -5589,7 +5662,9 @@ pub unsafe extern "C" fn oakengine_node_value_split_to_tracks(
t.den = n.den;
}
value_type::COLOR | value_type::VEC2 | value_type::VEC3 | value_type::VEC4 => {
t.f = n.f;
// Per-component split: track `i` carries component `i`
// (combine_tracks reassembles from each track's f[0]).
t.f = [n.f[i], 0.0, 0.0, 0.0];
}
value_type::FLOAT | value_type::BEZIER => {
t.f[0] = n.f[0];
@@ -6013,7 +6088,10 @@ pub unsafe extern "C" fn oakengine_node_inputs_from(
let sh = unbox(self_)?;
let oh = unbox(other)?;
// BFS over the module's output connections starting at `other`
// (inputs_from: is `other` reachable feeding into `self`?).
// (inputs_from: is `other` reachable feeding into `self`?). Every
// discovered neighbor is checked against the target, so a DIRECT
// feeder is found while expanding the depth-0 frontier; recursion
// merely widens the search beyond it.
let target = n::oaknode_node_identity(sh);
let mut frontier = vec![oh];
let mut visited: Vec<usize> = Vec::new();
@@ -6038,6 +6116,12 @@ pub unsafe extern "C" fn oakengine_node_inputs_from(
if n::oaknode_node_output_connection_node_at(cur, i, &mut out) == 0
&& !out.is_null()
{
// Direct feeders are identified at discovery, before
// the depth counter advances (recursive == 0 must
// still inspect `other`'s own outputs).
if n::oaknode_node_identity(out) == target {
return Ok(1);
}
next.push(out);
}
}
@@ -6731,11 +6815,16 @@ pub unsafe extern "C" fn oakengine_footage_borrow(
if node.is_null() {
return Ok(std::ptr::null_mut());
}
let h = unbox(node)?;
let mut h = unbox(node)?;
if !is_node_type(h, TYPE_ID_FOOTAGE) {
set_footage_error("node is not a footage node");
return Ok(std::ptr::null_mut());
}
// The borrow takes its OWN reference (addref) so freeing both the
// borrow and the source node shell later is double-free-safe.
if let Some(addref) = h.addref {
unsafe { addref(h.ctx) };
}
Ok(box_handle::<OakEngineFootage>(h))
})
}
+9 -8
View File
@@ -193,12 +193,13 @@ pub unsafe extern "C" fn oakengine_renderer_create(
if seq.is_null() || width <= 0 || height <= 0 || frame_rate_num <= 0 || frame_rate_den <= 0 {
return Ok(std::ptr::null_mut());
}
// Validate the pixel format against the oakcommon format enum.
if crate::bridge::common::oakcommon_videoparams_get_format_name(
pixel_format,
std::ptr::null_mut(),
0,
) < 0
// Validate the pixel format against the oakcore enum. The
// oakcommon format_name lookup succeeds for ANY code (unknowns
// format as "Unknown (0x…)"), so only the real formats (U8..F32)
// are accepted; Invalid (-1), the Count sentinel (5) and garbage
// codes are rejected.
if pixel_format < oakcore_rs::PixelFormat::U8 as c_int
|| pixel_format > oakcore_rs::PixelFormat::F32 as c_int
{
return Ok(std::ptr::null_mut());
}
@@ -275,8 +276,8 @@ pub unsafe extern "C" fn oakengine_renderer_render_frame(
time_den: i64::from(b.frame_rate_num),
color_manager: CHandle::null(),
mode: b.mode,
force_width: 0,
force_height: 0,
force_width: b.width,
force_height: b.height,
force_matrix: [0.0; 16],
has_force_matrix: 0,
force_format: -1,
+32 -4
View File
@@ -82,6 +82,11 @@ struct TaskMeta {
/// The project a save task writes (addref'd at creation, released at
/// free) — the module has no save-project getter.
save_project: Option<CHandle>,
/// The project an import task borrows (addref'd at creation, released
/// at free): the module's import task stores its project handle WITHOUT
/// addref, so this facade-side ref keeps the shared box alive while the
/// task runs (see `oakengine_task_create_project_import`).
import_project: Option<CHandle>,
/// The encoding-params box an export task owns, dropped at free
/// (mirrors the C++ `FacadeExportTask` destructor; stored as `usize` so
/// the map stays `Send`).
@@ -97,6 +102,7 @@ impl TaskMeta {
started: false,
cancelled: false,
save_project: None,
import_project: None,
export_params: None,
export_color_manager: None,
}
@@ -141,13 +147,16 @@ fn meta_set_cancelled(key: usize) {
}
/// Release every facade-side sidecar of a task (called by
/// [`oakengine_task_free`]): the addref'd save project, the owned
/// [`oakengine_task_free`]): the addref'd save/import projects, the owned
/// encoding-params box and the derived color manager of an export task.
fn drop_task_meta(key: usize) {
if let Some(meta) = meta_lock().remove(&key) {
if let Some(mut project) = meta.save_project {
unsafe { n::oaknode_project_free(&mut project) };
}
if let Some(mut project) = meta.import_project {
unsafe { n::oaknode_project_free(&mut project) };
}
if let Some(ptr) = meta.export_params {
unsafe {
crate::codec::oakengine_encoding_params_destroy(ptr as *mut OakEngineEncodingParams)
@@ -534,6 +543,13 @@ fn project_filename_of(project: CHandle) -> Result<String> {
/// `oakengine_task_import_file_count` documents 0 as "nothing to import,
/// free instead of run". `url_count < 0`, a NULL URL inside the array, or a
/// folder with no project yield NULL.
///
/// The module's import task stores the project handle WITHOUT addref, so
/// the facade keeps an addref'd copy in [`TaskMeta::import_project`]
/// (released at free) — without it, releasing the transient borrowed
/// handle here would drop the shared box while the task still references
/// it, and the run would read freed memory (the former SIGSEGV reproduced
/// by `it_task::import_run_single_file`).
#[no_mangle]
pub unsafe extern "C" fn oakengine_task_create_project_import(
folder: *mut OakEngineNode,
@@ -551,10 +567,22 @@ pub unsafe extern "C" fn oakengine_task_create_project_import(
return Ok(std::ptr::null_mut());
}
let h = t::oaktask_create_project_import(fh, project, urls, url_count);
// Release the transient borrowed project handle (the import task
// keeps its own copy).
if h.is_null() {
// Creation failed: release the transient borrowed handle.
n::oaknode_project_free(&mut project);
return Ok(std::ptr::null_mut());
}
// Keep the project borrowed for the task's lifetime (the module's
// import task stores the handle without addref): addref before the
// transient handle below is released, so the shared box stays alive
// until `oakengine_task_free` drops the meta-side copy.
let mut meta = TaskMeta::new();
meta.import_project = Some(project.addref());
// Release the transient borrowed project handle (the task's copy and
// the facade-side addref above keep the box alive).
n::oaknode_project_free(&mut project);
Ok(box_task(h))
meta_insert(h.ctx as usize, meta);
Ok(box_handle::<OakEngineTask>(h))
})
}
+65 -30
View File
@@ -2158,7 +2158,11 @@ pub unsafe extern "C" fn oakengine_sequence_ripple_delete_clip(
out_num as i64,
out_den as i64,
);
release_handle(track);
// NOTE: `track` is intentionally NOT released — the module command
// stores the borrowed handle for its whole lifetime (its `redo`/
// `undo` re-resolve the track), and the module model keeps such
// handles alive for the command's lifetime (same as
// `oakengine_sequence_delete_clips`).
if cmd.is_null() {
set_seq_error("ripple delete command failed");
return Err(Error::Failed("ripple delete command failed".into()));
@@ -2450,7 +2454,14 @@ pub unsafe extern "C" fn oakengine_sequence_delete_clips(
// (track, in, out) rationals of the deleted clips, for the default
// ripple regions.
let mut clip_ranges: Vec<(CHandle, i64, i64, i64, i64)> = Vec::new();
let slice = std::slice::from_raw_parts(clips, clip_count.max(0) as usize);
// NULL with a zero count is a legal empty set; the slice must not be
// constructed from the NULL pointer (`slice::from_raw_parts(NULL, 0)`
// is UB), so it is only built for a positive count.
let slice: &[*mut OakEngineClip] = if clip_count > 0 {
std::slice::from_raw_parts(clips, clip_count as usize)
} else {
&[]
};
for (i, clip) in slice.iter().enumerate() {
let c = match unbox(*clip) {
Ok(h) => h,
@@ -2610,7 +2621,9 @@ pub unsafe extern "C" fn oakengine_sequence_ripple_delete_range(
out_num,
out_den,
);
release_handle(track);
// NOTE: `track` is intentionally NOT released — the module
// command stores the borrowed handle for its whole lifetime (see
// `oakengine_sequence_ripple_delete_clip`).
if cmd.is_null() {
return Err(Error::Failed("ripple delete command failed".into()));
}
@@ -2634,7 +2647,14 @@ pub unsafe extern "C" fn oakengine_clip_toggle_enabled(
return Err(Error::Invalid);
}
let mut children: Vec<CHandle> = Vec::new();
let slice = std::slice::from_raw_parts(clips, count as usize);
// NULL with a zero count is a legal empty set; the slice must not be
// constructed from the NULL pointer (`slice::from_raw_parts(NULL, 0)`
// is UB), so it is only built for a positive count.
let slice: &[*mut OakEngineClip] = if count > 0 {
std::slice::from_raw_parts(clips, count as usize)
} else {
&[]
};
for (i, clip) in slice.iter().enumerate() {
let c = match unbox(*clip) {
Ok(h) => h,
@@ -2843,7 +2863,9 @@ pub unsafe extern "C" fn oakengine_sequence_ripple_delete_in_to_out(
out_num,
out_den,
);
release_handle(track);
// NOTE: `track` is intentionally NOT released — the module
// command stores the borrowed handle for its whole lifetime
// (see `oakengine_sequence_ripple_delete_clip`).
if cmd.is_null() {
release_handle(wa);
return Err(Error::Failed("ripple remove command failed".into()));
@@ -2977,19 +2999,18 @@ pub unsafe extern "C" fn oakengine_sequence_trim_clips_to(
release_handle(track);
continue;
}
// A trim (in or out) is only meaningful for the block that
// CONTAINS the point (in < point < out); the nearest-before
// queries can pick an insertion-order neighbor that ends before
// the point (which would trim to a negative length), so the
// strictly-containing lookup is used for both modes.
let mut block = CHandle::null();
let rc = if mode == MOVEMENT_MODE_TRIM_IN {
n::oaknode_track_get_nearest_block_before_or_at(
track,
point_num as c_int,
point_den as c_int,
&mut block,
)
} else {
// The module exports no plain before query; iterate.
block = nearest_block_before(track, point_num, point_den);
if block.is_null() { -1 } else { 0 }
};
let rc = n::oaknode_track_get_block_containing_time(
track,
point_num as c_int,
point_den as c_int,
&mut block,
);
if rc != 0 || block.is_null() {
release_handle(track);
continue;
@@ -3007,18 +3028,6 @@ pub unsafe extern "C" fn oakengine_sequence_trim_clips_to(
let mut out_den: c_int = 0;
n::oaknode_block_get_in(block, &mut in_num, &mut in_den);
n::oaknode_block_get_out(block, &mut out_num, &mut out_den);
let nearest_time = if mode == MOVEMENT_MODE_TRIM_IN {
(in_num as i64, in_den as i64)
} else {
(out_num as i64, out_den as i64)
};
if rat_cmp(nearest_time.0, nearest_time.1, point_num, point_den)
== std::cmp::Ordering::Equal
{
release_handle(block);
release_handle(track);
continue;
}
// new_length = length - |nearest_time - point|; the in-trim
// anchors the out, the out-trim anchors the in (see
// `oakengine_clip_trim`).
@@ -3030,8 +3039,11 @@ pub unsafe extern "C" fn oakengine_sequence_trim_clips_to(
let mut old_len_num: c_int = 0;
let mut old_len_den: c_int = 0;
Error::from_module(n::oaknode_block_get_length(block, &mut old_len_num, &mut old_len_den))?;
// Trim the addressed block itself (`trim_cmd` anchors on the
// block handle; passing the track used to silently reject the
// trim in the module).
let cmd = trim_cmd(
track,
block,
mode,
old_len_num,
old_len_den,
@@ -3071,6 +3083,10 @@ pub unsafe extern "C" fn oakengine_sequence_delete_empty_tracks(
return Err(Error::Invalid);
}
let mut children: Vec<CHandle> = Vec::new();
// (track, owning list) pairs for the live removal compensation
// (the module's `TimelineRemoveTrackCommand` redo is a no-op for the
// list structure; see below).
let mut to_remove: Vec<(CHandle, CHandle)> = Vec::new();
let mut removed: c_int = 0;
let mut all_count: c_int = 0;
Error::from_module(n::oaknode_sequence_get_all_track_count(sequence, &mut all_count))?;
@@ -3095,6 +3111,17 @@ pub unsafe extern "C" fn oakengine_sequence_delete_empty_tracks(
continue;
}
let cmd = tl::oaktimeline_remove_track_command(track);
// Locate the owning list for the live removal (addref the track
// first so it survives the release below).
let mut ttype: c_int = 0;
if n::oaknode_track_get_type(track, &mut ttype) == 0 {
let mut list = CHandle::null();
if n::oaknode_sequence_get_track_list(sequence, ttype, &mut list) == 0
&& !list.is_null()
{
to_remove.push((track.addref(), list));
}
}
release_handle(track);
if cmd.is_null() {
return Err(Error::Failed("remove track command failed".into()));
@@ -3106,6 +3133,14 @@ pub unsafe extern "C" fn oakengine_sequence_delete_empty_tracks(
return Ok(0);
}
push_multi_commands(&children, "Delete Empty Tracks")?;
// The module's TimelineRemoveTrackCommand redo is a no-op for the
// list structure (undogeneral.rs NOTE), so the removal is applied
// live as compensation (the same documented deviation as
// `oakengine_sequence_remove_track`).
for (track, list) in &to_remove {
n::oaknode_tracklist_remove_track(*list, *track);
release_handle(*list);
}
Ok(removed)
})
}
+46 -5
View File
@@ -93,7 +93,15 @@ pub(crate) unsafe fn push_or_run(command_box: *mut OakEngineClipboard, name: *co
return if rc == 0 { Ok(()) } else { Err(Error::Module(rc)) };
}
let stack = *global_stack();
let rc = unsafe { u::oakundo_undostack_push(stack, cmd, label.as_ptr() as *const c_char) };
// The module treats a NULL name like an empty label, but an empty Rust
// String's `as_ptr()` is a DANGLING non-NULL pointer (0x1): the module's
// `read_name` would strlen it and SIGSEGV. Pass a real NULL instead.
let label_ptr = if label.is_empty() {
std::ptr::null()
} else {
label.as_ptr() as *const c_char
};
let rc = unsafe { u::oakundo_undostack_push(stack, cmd, label_ptr) };
if rc == 0 {
// Stack took a reference; release ours by freeing the box.
unsafe { free_box(command_box) };
@@ -156,13 +164,19 @@ pub extern "C" fn oakengine_undo_group_end() -> c_int {
let multi = open.multi;
let name = open.name;
drop(g);
// Same NULL-for-empty convention as `push_or_run`: the module's
// `read_name` treats NULL like an empty label, while an empty String's
// dangling `as_ptr()` (0x1) would be strlen'd -> SIGSEGV.
let name_ptr = if name.is_empty() {
std::ptr::null()
} else {
name.as_ptr() as *const c_char
};
// push_pre_executed discards an empty multi command. Either way
// the stack took (or destroyed) the command; release our own
// reference to the multi handle.
let stack = *global_stack();
let rc = unsafe {
u::oakundo_undostack_push_pre_executed(stack, multi, name.as_ptr() as *const c_char)
};
let rc = unsafe { u::oakundo_undostack_push_pre_executed(stack, multi, name_ptr) };
let mut multi_handle = multi;
unsafe { u::oakundo_command_free(&mut multi_handle) };
if rc == 0 {
@@ -181,10 +195,37 @@ pub extern "C" fn oakengine_undo_group_abort() -> c_int {
let mut g = group_lock();
let open = g.take().ok_or(Error::State)?;
drop(g);
let rc = unsafe { u::oakundo_command_undo_now(open.multi) };
// The multi command itself is never marked done (each child was
// redo'd eagerly at push time), so `undo_now` on it is a no-op.
// Undo the executed children individually instead, in reverse
// insertion order (mirroring the multi's reverse-order undo), each
// through its own borrowed handle.
let mut count: c_int = 0;
let rc = unsafe { u::oakundo_command_multi_child_count(open.multi, &mut count) };
if rc != 0 {
let mut multi = open.multi;
unsafe { u::oakundo_command_free(&mut multi) };
return Err(Error::Module(rc));
}
for i in (0..count).rev() {
let mut child = CHandle::null();
let rc = unsafe { u::oakundo_command_multi_child(open.multi, i, &mut child) };
if rc != 0 {
let mut multi = open.multi;
unsafe { u::oakundo_command_free(&mut multi) };
return Err(Error::Module(rc));
}
let rc = unsafe { u::oakundo_command_undo_now(child) };
// The child handle is borrowed (owns:false): release only its
// shell — the child value lives on in the multi until the multi
// itself is freed below.
unsafe { u::oakundo_command_free(&mut child) };
if rc != 0 {
let mut multi = open.multi;
unsafe { u::oakundo_command_free(&mut multi) };
return Err(Error::Module(rc));
}
}
let mut multi = open.multi;
unsafe { u::oakundo_command_free(&mut multi) };
Ok(())