refactor: purge CHandle from module internals (M14 R5)

Module-internal object references are Rust types now (values, Arc,
Mutex); CHandle remains only at the oakengine C-ABI boundary:

- oakundo: the global stack holds UndoStack/UndoCommand values
  directly (stack token is the static's address)
- oaktimeline: marker/workarea boxes carry Arc<Mutex<T>>; commands
  share the same allocation through Arc clones (readers in oakengine
  stubs and the app's graphops updated to lock)
- oaktask/oakstorage: sessions, write-through bindings and the
  database backend pass ProjectArc; the Session drops its manual
  release bookkeeping; nodeutil keeps the CHandle<->Arc boundary
  conversion (release_project restored for the app)
- oakcodec: handle.rs deleted outright (no facade entry needed it);
  texture/block placeholders are unit structs
- oakrender: copier's project handle is an identity u64; alive-count
  machinery removed; handle.rs is make_owned/get/get_mut only
- oakplugin: the instance registry is gone (its unregister key never
  matched, leaking weak entries); handle.rs is the RefBox boundary type
- oaknode/oakcommon: only dead guard/borrow helpers removed; external
  payload handles (texture/processor) documented as the boundary

Flake hunts landed along the way: the audio recording test serializes
on the shared manager lock with a normalized state; the autocacher
cancel test uses a slow producer so cancellation is deterministic.
This commit is contained in:
2026-08-17 16:40:15 +08:00
parent ede03d0bfe
commit b36cbd6b6f
53 changed files with 1260 additions and 2369 deletions
+127 -144
View File
@@ -24,41 +24,43 @@
//! lets downstream modules (the oakstorage write-through session manager)
//! subscribe to "a command was recorded" notifications without a facade
//! round-trip.
//!
//! M14 R5: everything in this module is Rust-typed — the process-wide
//! stack is a plain `static Mutex<UndoStack>` and the open group holds an
//! [`UndoCommand`] value. The only remaining `CHandle` is the
//! [`push_or_run`] facade entry, which converts the incoming module
//! command handle into an [`UndoCommand`] value at the boundary.
use std::ffi::{c_char, c_int, c_void};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
use crate::error::{Error, OAKUNDO_E_FAILED, Result};
use crate::handle::CHandle;
use crate::undocommand::{
command_free, command_from_owned, command_init_multi, command_multi_add_child,
command_multi_child, command_multi_child_count, command_redo_now, command_undo_now,
UndoCommand,
};
use crate::undostack::{
undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done,
undostack_command_text, undostack_count, undostack_index, undostack_init, undostack_jump,
undostack_push, undostack_push_pre_executed,
command_free, command_from_owned, command_redo_now, command_take, UndoCommand,
};
use crate::undostack::UndoStack;
/// The process-wide undo stack handle (`OakUndoStack`), created lazily
/// on first use and kept for the process lifetime.
fn global_stack() -> &'static CHandle {
static STACK: OnceLock<CHandle> = OnceLock::new();
STACK.get_or_init(|| undostack_init())
/// The process-wide undo stack, created lazily on first use and kept for
/// the process lifetime.
fn global_stack() -> &'static Mutex<UndoStack> {
static STACK: OnceLock<Mutex<UndoStack>> = OnceLock::new();
STACK.get_or_init(|| Mutex::new(UndoStack::new()))
}
/// Stable opaque token for the engine's `oakengine_undo_handle` export:
/// the stack handle's `ctx` pointer (never dereferenced by callers; lives
/// for the process).
/// the stack's address (never dereferenced by callers; lives for the
/// process).
pub fn stack_token() -> *mut c_void {
global_stack().ctx
global_stack() as *const Mutex<UndoStack> as *mut c_void
}
/// Borrowed copy of the process-wide stack handle (for the module-level
/// queries below).
fn stack() -> CHandle {
*global_stack()
/// Run `f` on the process-wide stack; a poisoned mutex is recovered (its
/// inner value is still valid).
fn with_stack<R>(f: impl FnOnce(&mut UndoStack) -> Result<R>) -> Result<R> {
let mut guard = global_stack().lock().unwrap_or_else(|e| e.into_inner());
f(&mut guard)
}
// ---------------------------------------------------------------------------
@@ -99,10 +101,10 @@ fn notify_observers() {
// Undo group
// ---------------------------------------------------------------------------
/// The currently open undo group (a multi command handle) plus its name.
/// The currently open undo group (a multi command value) plus its name.
struct OpenGroup {
/// Multi command handle; owned by this state until end/abort.
multi: CHandle,
/// Multi command value; owned by this state until end/abort.
multi: UndoCommand,
/// Group label.
#[allow(dead_code)]
name: String,
@@ -121,12 +123,8 @@ pub fn group_begin(name: &str) -> Result<()> {
if g.is_some() {
return Err(Error::State);
}
let multi = command_init_multi();
if multi.is_null() {
return Err(Error::NoMem);
}
*g = Some(OpenGroup {
multi,
multi: UndoCommand::multi(),
name: name.to_string(),
});
Ok(())
@@ -141,28 +139,16 @@ pub fn group_end() -> Result<()> {
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 rc = undostack_push_pre_executed(stack(), multi, name_ptr);
let mut multi_handle = multi;
command_free(&mut multi_handle);
if rc == 0 {
// The group's children were redo'd eagerly at push time; the whole
// group is one command (commit at group_end).
notify_observers();
// push_pre_executed discards an empty multi command; either way the
// stack takes (or destroys) the command value.
with_stack(|s| {
s.push_pre_executed(multi, &name);
Ok(())
} else {
Err(Error::from_code(rc))
}
})?;
// The group's children were redo'd eagerly at push time; the whole
// group is one command (commit at group_end).
notify_observers();
Ok(())
}
/// Undo all executed children and discard the group. [`Error::State`] when
@@ -170,40 +156,17 @@ pub fn group_end() -> Result<()> {
pub fn group_abort() -> Result<()> {
let mut g = group_lock();
let open = g.take().ok_or(Error::State)?;
let mut multi = open.multi;
drop(g);
// 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 = command_multi_child_count(open.multi, &mut count);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
// order (mirroring the multi's reverse-order undo).
let count = multi.multi_child_count();
for i in (0..count).rev() {
let mut child = CHandle::null();
let rc = command_multi_child(open.multi, i, &mut child);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
let rc = 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.
command_free(&mut child);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
multi.multi_child_mut(i)?.undo_now();
}
let mut multi = open.multi;
command_free(&mut multi);
// The multi command value drops here, freeing the children.
Ok(())
}
@@ -215,37 +178,42 @@ pub fn group_abort() -> Result<()> {
/// the STACK — a group child joins the group, and the group itself
/// notifies at [`group_end`].
pub fn push_or_run(command: CHandle, name: &str) -> c_int {
let g = group_lock();
if let Some(group) = g.as_ref() {
// The module's `command_multi_add_child` consumes the child's
// command value (command_take), so the eager redo must happen on the
// still-owned handle FIRST — the group takes the already-done
// command (C++ semantics: add_child + redo_now, net effect identical
// for the group's reverse-order undo).
let mut g = group_lock();
if let Some(group) = g.as_mut() {
// The group takes the command value (command_take), so the eager
// redo must happen on the still-owned handle FIRST — the group
// receives an already-done command (C++ semantics: add_child +
// redo_now, net effect identical for the group's reverse-order
// undo).
let rc = command_redo_now(command);
if rc != 0 {
return rc;
}
let rc = command_multi_add_child(group.multi, command);
drop(g);
return rc;
// SAFETY: `command` is a live module command handle (facade
// contract); `command_take` moves the value out of its box and
// marks the shell non-owning.
return match unsafe { command_take(command.ctx) } {
Ok(cmd) => {
group.multi.multi_add_child(cmd);
0
}
Err(e) => e.code(),
};
}
let stack = stack();
// 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 name.is_empty() {
std::ptr::null()
} else {
name.as_ptr() as *const c_char
// No group open: take the command value and push it onto the
// process-wide stack (redo then record).
// SAFETY: as above, `command` is a live module command handle.
let cmd = match unsafe { command_take(command.ctx) } {
Ok(cmd) => cmd,
Err(e) => return e.code(),
};
let rc = undostack_push(stack, command, label_ptr);
if rc == 0 {
// The stack took a reference; the command's redo already ran (plan
// M13 D2): persist the write-through subscribers.
notify_observers();
}
rc
let mut guard = global_stack().lock().unwrap_or_else(|e| e.into_inner());
guard.push(cmd, name);
drop(guard);
// The stack took the command; its redo already ran (plan M13 D2):
// persist the write-through subscribers.
notify_observers();
0
}
// ---------------------------------------------------------------------------
@@ -309,87 +277,102 @@ pub fn redoable() -> bool {
/// Total number of history rows.
pub fn count() -> Result<i64> {
let mut c: i64 = 0;
let rc = undostack_count(stack(), &mut c);
if rc == 0 {
Ok(c)
} else {
Err(Error::from_code(rc))
}
with_stack(|s| Ok(s.command_count()))
}
/// Current position in the history (done-command count).
pub fn index() -> Result<i64> {
let mut i: i64 = 0;
let rc = undostack_index(stack(), &mut i);
if rc == 0 {
Ok(i)
} else {
Err(Error::from_code(rc))
}
with_stack(|s| Ok(s.done_count()))
}
/// Whether an undo is possible (1/0 via `out_value`; a module error code
/// otherwise).
pub fn can_undo(out_value: *mut c_int) -> Result<()> {
let rc = undostack_can_undo(stack(), out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
if out_value.is_null() {
return Err(Error::Invalid);
}
let value = with_stack(|s| Ok(if s.can_undo() { 1 } else { 0 }))?;
// SAFETY: `out_value` points to at least one writable `c_int`, per the
// facade contract.
unsafe { *out_value = value };
Ok(())
}
/// Whether a redo is possible (1/0 via `out_value`; a module error code
/// otherwise).
pub fn can_redo(out_value: *mut c_int) -> Result<()> {
let rc = undostack_can_redo(stack(), out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
if out_value.is_null() {
return Err(Error::Invalid);
}
let value = with_stack(|s| Ok(if s.can_redo() { 1 } else { 0 }))?;
// SAFETY: `out_value` points to at least one writable `c_int`, per the
// facade contract.
unsafe { *out_value = value };
Ok(())
}
/// Undo/redo until the done-command count equals `index`. On success the
/// bound projects are written through (the jump executed the undo/redo
/// callbacks that mutated them) via the command observers.
pub fn jump(index: i64) -> Result<()> {
let rc = undostack_jump(stack(), index);
if rc == 0 {
notify_observers();
with_stack(|s| {
s.jump(index);
Ok(())
} else {
Err(Error::from_code(rc))
}
})?;
notify_observers();
Ok(())
}
/// Delete all commands and push the fresh "New/Open Project" empty command.
pub fn clear() -> Result<()> {
let rc = undostack_clear(stack());
if rc == 0 {
with_stack(|s| {
s.clear();
Ok(())
} else {
Err(Error::from_code(rc))
}
})
}
/// Two-stage label getter for the row at `row` (see
/// [`crate::undostack::undostack_command_text`]): returns the required
/// size including the NUL, or a module error code.
pub fn command_text(row: i64, buf: *mut c_char, buf_size: c_int) -> c_int {
undostack_command_text(stack(), row, buf, buf_size)
let result = catch_unwind(AssertUnwindSafe(|| -> Result<i32> {
with_stack(|s| {
if row < 0 || row >= s.command_count() {
return Err(Error::NotFound);
}
let name = s.command_name(row)?;
let required = (name.len() + 1) as i32;
if !buf.is_null() && buf_size > 0 {
let copy_len = name.len().min((buf_size as usize).saturating_sub(1));
let bytes = name.as_bytes();
// SAFETY: `buf` points to `buf_size` writable bytes and we
// write at most `copy_len` (+ one NUL) of them.
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);
*buf.add(copy_len) = 0;
}
}
Ok(required)
})
}));
match result {
Ok(Ok(required)) => required,
Ok(Err(e)) => e.code(),
Err(_) => OAKUNDO_E_FAILED,
}
}
/// Whether the row at `row` is done (1/0 via `out_value`; a module error
/// code otherwise — `-20004` for an out-of-range row).
pub fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> {
let rc = undostack_command_is_done(stack(), row, out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
if out_value.is_null() {
return Err(Error::Invalid);
}
let done = with_stack(|s| s.command_is_done(row))?;
// SAFETY: `out_value` points to at least one writable `c_int`, per the
// facade contract.
unsafe { *out_value = if done { 1 } else { 0 } };
Ok(())
}
#[cfg(test)]
+13
View File
@@ -201,6 +201,14 @@ impl UndoCommand {
}
}
/// Mutable reference to the child at `index` of a composite command.
pub fn multi_child_mut(&mut self, index: usize) -> Result<&mut UndoCommand> {
match &mut self.kind {
CommandKind::Multi(m) => m.child_mut(index),
CommandKind::Vtable { .. } => Err(Error::Invalid),
}
}
/// `redo_now` semantics: a no-op if already done.
pub fn redo_now(&mut self) {
if !self.done {
@@ -387,6 +395,11 @@ impl MultiUndoCommand {
self.children.get(index).ok_or(Error::NotFound)
}
/// Mutable child at `index`.
pub fn child_mut(&mut self, index: usize) -> Result<&mut UndoCommand> {
self.children.get_mut(index).ok_or(Error::NotFound)
}
/// Redo all children in order.
pub fn redo(&mut self) {
for child in self.children.iter_mut() {