refactor(engine): pure cdylib + undo-stack test race fix (M14 R4)

- oakengine is now cdylib-only (no rlib/staticlib consumers anywhere;
  cargo tree verified) — the plugin/external C ABI layer; README and
  docs updated
- cd.yml drops the dylib embedding/re-sign steps (the app no longer
  links it)
- test race root-caused and fixed for good: the global undo stack lock
  is now a re-entrant mutex (parking_lot) shared by every test that
  drives the stack, including the previously unlocked node/render
  families; the render-manager serial-ordering bug (an earlier repro
  test initialized the global manager before the not-initialized test)
  is fixed with a shared SERIAL guard and a manager shutdown
- 5 consecutive parallel runs clean; serial 209/209
This commit is contained in:
2026-08-17 00:51:36 +08:00
parent 022c0a7a5a
commit c5f1d0d76c
20 changed files with 214 additions and 190 deletions
+17 -44
View File
@@ -77,13 +77,12 @@ jobs:
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
file icons/icon.png
# oakengine is a workspace member but NOT a default member; the
# app/CLI/worker link scripts point the linker at the profile dir
# (target/<profile>/), so build its cdylib there before the rest.
# Build the packaged binaries (default members: the app, oak-cli,
# oak-worker). oakengine is a workspace member but NOT a default
# member (M14 R4: pure cdylib for plugins/external consumers — no
# packaged binary links it), so it is not built here.
- name: Build (release)
run: |
cargo build --release --locked -p oakengine
cargo build --release --locked
run: cargo build --release --locked
- name: Package (deb, AppImage, pacman)
run: cargo packager --release --formats deb,appimage,pacman
@@ -112,11 +111,10 @@ jobs:
if-no-files-found: error
# # ------------------------------------------------------------------
# macOS: Apple Silicon only. cargo-packager cannot fold liboakengine.dylib
# into the bundle (no post-packaging hook; the .app is rebuilt on every
# run), so we package the .app with cargo-packager, embed the dylib next
# to the binaries with install_name_tool, ad-hoc re-sign, then create the
# DMG with hdiutil (the same way the old C++ CD did).
# macOS: Apple Silicon only. The app/CLI/worker no longer link
# liboakengine (M14 R4: they call the module rlibs directly), so the
# .app bundle carries no dylib to fold in; package it with
# cargo-packager and create the DMG with hdiutil.
# ------------------------------------------------------------------
macos:
name: macOS DMG (Apple Silicon)
@@ -175,42 +173,16 @@ macos:
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
file icons/icon.png
# oakengine is a workspace member but NOT a default member; the
# app/CLI/worker link scripts point the linker at the profile dir
# (target/<profile>/), so build its cdylib there before the rest.
# Build the packaged binaries (default members: the app, oak-cli,
# oak-worker). oakengine is a workspace member but NOT a default
# member (M14 R4: pure cdylib for plugins/external consumers — no
# packaged binary links it), so it is not built here.
- name: Build (release)
run: |
cargo build --release --locked -p oakengine
cargo build --release --locked
run: cargo build --release --locked
- name: Package .app bundle
run: cargo packager --release --formats app
- name: Embed liboakengine.dylib and re-sign
run: |
set -euo pipefail
APP="target/release/Oak.app"
MACOS="$APP/Contents/MacOS"
# cargo may place the dylib in target/release/deps/ (dependency
# build) or target/release/ (workspace-member build); derive it
# from the main binary's own load reference so the bundled copy
# matches exactly what the binary was linked against.
REF="$(otool -L "$MACOS/oak-editor" | awk '/liboakengine\.dylib/ {print $1; exit}')"
test -n "$REF" && test -f "$REF"
cp "$REF" "$MACOS/liboakengine.dylib"
install_name_tool -id "@executable_path/liboakengine.dylib" "$MACOS/liboakengine.dylib"
# build.rs links the app against the dylib's absolute build path;
# point every binary that references it at the bundled copy.
for bin in oak-editor oak-cli oak-worker; do
if otool -L "$MACOS/$bin" 2>/dev/null | grep -q "liboakengine.dylib"; then
REF="$(otool -L "$MACOS/$bin" | awk '/liboakengine/ {print $1; exit}')"
install_name_tool -change "$REF" "@executable_path/liboakengine.dylib" "$MACOS/$bin"
fi
done
# install_name_tool invalidates the packager's ad-hoc signature.
codesign --force --deep --sign - "$APP"
codesign -dv "$APP" 2>&1 | head -3
- name: Create DMG
run: |
rm -rf dmg-staging
@@ -235,8 +207,9 @@ macos:
# `-Wl` flags that the MSVC linker rejects. The NSIS packaging itself is
# fine (cargo-packager downloads its own makensis, SHA-1 verified); the
# blocker is the binary link. Re-enable this job once crates link on
# Windows; the job below is kept verbatim (with the -p oakengine prebuild)
# as the reference.
# Windows; the job below is kept verbatim as the reference (the
# `-p oakengine` prebuild is obsolete since M14 R4 — no packaged binary
# links the cdylib — and should be dropped when re-enabling).
# ------------------------------------------------------------------
# windows:
# name: Windows installer (NSIS)
Generated
+1
View File
@@ -4836,6 +4836,7 @@ dependencies = [
"oaktask",
"oaktimeline",
"oakundo",
"parking_lot",
"sea-orm",
"serde",
"serde_json",
+7 -4
View File
@@ -33,10 +33,13 @@ exclude = ["gpui"]
# dependency, so it builds with the app). Build/test it explicitly with
# `cargo test -p oakstorage`.
# NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a
# workspace member, so `cargo test -p oakengine` works): its in-flight
# integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files
# and process-global facade state, which makes the parallel default-members
# run flaky. The app builds it as a regular path dependency instead.
# workspace member, so `cargo test -p oakengine` / `cargo test --workspace`
# still run its tests): it is the plugin/external-consumer cdylib (M14 R4) —
# no crate in the workspace links it (app/cli/worker call the module rlibs
# directly), so default builds skip it. Its in-crate unit tests
# (src/test_support/, the former tests/*.rs) share temp files and
# process-global facade state, which makes the parallel default-members run
# flaky.
default-members = [".", "crates/oak-cli", "crates/oak-worker"]
resolver = "2"
+5 -1
View File
@@ -2,9 +2,12 @@
name = "oakengine"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor facade: re-exports the frozen oakengine_* C ABI over the module Rust APIs (Rust)"
description = "Oak Video Editor plugin/external-C-ABI layer: the frozen oakengine_* C ABI as a pure cdylib over the module crates' direct Rust APIs"
license = "GPL-3.0-or-later"
# Pure cdylib (M14 R4): the only consumers are OFX plugins and external
# C-ABI embedders. No Rust crate depends on this crate, so no rlib/staticlib
# artifact is produced and it stays out of the workspace default-members.
[lib]
crate-type = ["cdylib"]
@@ -52,6 +55,7 @@ oakplugin = { path = "../oakplugin" }
oakstorage = { path = "../oakstorage" }
[dev-dependencies]
parking_lot = "0.12"
# The facade is cdylib-only, so the former tests/*.rs integration tests
# now run as unit tests inside the crate (src/test_support/, pulled in
# from src/lib.rs); the module crates are reachable through the normal
+74 -67
View File
@@ -1,64 +1,71 @@
# oakfacade — the `liboakengine` facade (Rust)
# oakengine — the `liboakengine` cdylib (plugin / external C ABI)
Re-exports the frozen `oakengine_*` C ABI (`engine/include/oakengine/*.h`)
verbatim on top of the module C ABIs (`include/<mod>/*.h`, implemented by
the oakundo/oaknode/oaktimeline/oakcodec/oakaudio/oakrender/oaktask/
oakcommon/oakplugin crates). This is the M9 §4 assembly layer: every
module call crosses the module C ABI as an `extern "C"` import; the
facade itself owns only cross-cutting state.
The frozen `oakengine_*` C ABI (`engine/include/oakengine/*.h`) as a
**pure cdylib**. This is the plugin / external-consumer layer: OFX plugins
and third-party embedders link `liboakengine` and call the C ABI. The app,
oak-cli and oak-worker do not use it anymore (M14 R4) — they link the
module crates directly as Rust rlibs. The C ABI itself is frozen: only
additive changes, plus major version bumps.
Downward, every export is a direct Rust call into the module crates
(oakundo/oaknode/oaktimeline/oakcodec/oakaudio/oakrender/oaktask/
oakcommon/oakplugin/oakstorage/oakcore-rs) through `src/stubs.rs` (the
rewired replacement for the deleted `bridge/`). The facade itself owns
only the engine's box/unbox, buf/size and error-code conventions;
cross-cutting state that used to live here (the process-wide undo stack,
the open undo group) has sunk into the modules (M14 R1:
`oakundo::global`).
## Architecture
```
src/
lib.rs crate docs, module list
lib.rs crate docs, module list, test-only test_link
error.rs OAKENGINE error codes (module 00 → -1..-6)
handle.rs CHandle mirror, OakEngine* opaque wrappers, box/unbox,
catch_unwind guards, buf/size string helpers
bridge/ extern "C" imports per module crate (the only way the
facade talks to modules)
undo.rs engine/include/oakengine/undo.h
stubs.rs direct-Rust shims replacing the deleted bridge/ (the
engine's only downward path to the modules)
linkage.rs #[used] anchors pulling every module rlib into the cdylib
undo.rs engine/include/oakengine/undo.h (thin forward to
oakundo::global)
common.rs config.h + videoparams.h (facade-static tables + POD↔handle)
audio.rs audio.h (manager + sync + processor)
codec.rs encoding.h (metadata over oakcodec + facade params POD box)
codec.rs encoding.h + exporter.h (metadata, params POD, exporter)
render.rs renderer.h + color.h + lut.h (renderer/frame/color processor)
plugin.rs plugin.h
node.rs node.h + project.h + footage.h (node graph / project / footage)
timeline.rs timeline.h (sequences, clips, tracks, markers, workarea)
task.rs task.h (background tasks over oaktask)
storage.rs write-through session (project ↔ oakstorage backend)
deferred.rs documented deferrals (stub detail lives here and in the
family modules' stub bodies)
tests/
common/mod.rs test support: force-link + oakcore/ffmpeg_bridge stubs
undo.rs, common.rs, audio.rs, codec.rs, render.rs, plugin.rs, linkage.rs
test_support/ the former tests/*.rs, now in-crate unit tests
```
### Dependencies
The facade's regular dependency is `thiserror` (the `Display` +
`std::error::Error` impl for the facade error enum, `src/error.rs`). Every
module
call crosses the module C ABI as an `extern "C"` import (`src/bridge/`),
and the module crates themselves are real dependencies: [`linkage`](src/linkage.rs)
anchors them so their `#[no_mangle]` exports are linked into the
`liboakengine` cdylib — the dylib carries the module C ABIs (oakundo_*,
oakcommon_*, oaktimeline_*, oakcodec_*, oakaudio_*, oakrender_*,
oaktask_*, oakplugin_*, oaknode_*) next to the facade's oakengine_*
exports. The `oakcore_audioparams_*` accessors the audio paths read
through are implemented in the dylib too (src/stubs.rs, module `audio`,
M12 P5) — they used to be C++ host symbols left as runtime lookups
(macOS `-undefined dynamic_lookup`); the cdylib now carries no undefined
imports.
module call is a compile-time Rust call into the module crate's direct API
(`src/stubs.rs`); the module crates are real dependencies, and
[`linkage`](src/linkage.rs) anchors them so their rlibs are embedded in
the `liboakengine` cdylib next to the facade's `oakengine_*` exports. The
`oakcore_audioparams_*` accessors the audio paths read through are
implemented in the dylib too (src/stubs.rs, module `audio`, M12 P5) —
they used to be C++ host symbols left as runtime lookups (macOS
`-undefined dynamic_lookup`); the cdylib now carries no undefined imports.
### Handle mapping
The engine headers' opaque pointers (`OakEngineNode*`, `OakEngineTrack*`,
...) are thin newtype wrappers around the module C ABI's `CHandle`
...) are thin newtype wrappers around the module layer's `CHandle`
(`{ctx, addref, release, abi_version}`) values. A box is created by
`handle::box_handle` and freed by `handle::free_box` (release + dealloc);
consuming exports (`oakengine_*_free`, `oakengine_undo_push`, ...) free
their box, borrowed results never are. The facade's own process-wide undo
stack and open undo group live in `undo.rs` (module 00 analogues of
their box, borrowed results never are. The process-wide undo stack and
open undo group live in the oakundo module (`oakundo::global`); the
undo.h exports are thin forwards over it (module 00 analogues of
`EngineCore::undo_stack()` and the C++ capi's `g_undo_group`).
### Error codes
@@ -73,56 +80,56 @@ convention: the return value is the length excluding the NUL
| Family | Header | Wrapped | Notes |
|---|---|---|---|
| undo | undo.h | 37 | stack/group/command lifecycle + Qt leftovers (update_actions/actions → no-op/NULL) |
| undo | undo.h | 37 | stack/group/command lifecycle over oakundo (`oakundo::global` + undocommand) + Qt leftovers (update_actions/actions → no-op/NULL) |
| common | config.h, videoparams.h | 34 | config over oakcommon; videoparams static tables ported from `engine/render/videoparams.cpp` |
| audio | audio.h | 26 | manager + sync; processor convert/output_params stubbed (interface mismatch) |
| plugin | plugin.h | 4 | callbacks are facade state; push_button stubbed (no module API) |
| codec | encoding.h | 81/85 | metadata family over oakcodec (`include/codec/format.h`); params handle is a facade box over the `oakcodec_encoding_params` POD; presets/load-save/export stubs |
| codec | encoding.h, exporter.h | 81/85 | metadata family over oakcodec (`include/codec/format.h`); params handle is a facade box over the `oakcodec_encoding_params` POD; the exporter family drives the export task synchronously (integration-tested, real mp4 via FFmpeg); presets/load-save deferred |
| render | renderer.h, color.h, lut.h | 60/60 | renderer over oakrender tickets; frame accessors over `OakCodecFrame`; color processor over `oakrender_color_processor_*`; color-manager list queries + LUT library stubs |
| node | node.h, project.h, footage.h | 226/327 | the node graph, project and footage families over the oaknode C ABI (`include/node/*.h`); 101 documented stubs where the module lacks the surface (gizmos, plugin messages, input properties, brush, thumbnail/waveform caches, shape/subtitle, keyframe enumeration, ...) — see the stub bodies |
| timeline | timeline.h | 126/139 | sequences/clips/tracks/markers/workarea over oaknode + oaktimeline; 13 documented stubs (ripple-tracks command, default transitions, move-track/clip, marker-create, auto-cache, cache invalidation, multicam find/switch — module-surface gaps, see the stub bodies) |
| node | node.h, project.h, footage.h | 226/327 | the node graph, project and footage families over the oaknode crate; documented stubs where the module lacks the surface (gizmos, plugin messages, input properties, brush, thumbnail/waveform caches, shape/subtitle, keyframe enumeration, ...) — see the stub bodies |
| timeline | timeline.h | 126/139 | sequences/clips/tracks/markers/workarea over oaknode + oaktimeline; documented stubs (ripple-tracks command, default transitions, move-track/clip, marker-create, auto-cache, cache invalidation, multicam find/switch — module-surface gaps, see the stub bodies) |
| task | task.h | 27 | the background-task system over oaktask (manager + load/save/import/export creators + result accessors); `create_proxy` stubbed (the module has no proxy-task C creator); start-time/is-cancelled are facade-approximated |
Deferred/stub detail lives in [`deferred`] and in the stub bodies' doc
comments. The worker/IPC families (`worker.h`/`ipc.h`) are **not** part of
the facade anymore: the frozen C++ ABI does not include them, so the
render worker's runtime and the shared-memory frame-slot transport moved
into the `oak-worker` crate (`crates/oak-worker/src/{worker,ipc}.rs`,
self-contained, direct Rust calls into oakrender); the ipc.h control-plane
message serializers remain unwrapped.
Deferred/stub detail lives in [`deferred`](src/deferred.rs) and in the
stub bodies' doc comments. The worker/IPC families (`worker.h`/`ipc.h`)
are **not** part of the facade anymore: the frozen C++ ABI does not
include them, so the render worker's runtime and the shared-memory
frame-slot transport moved into the `oak-worker` crate
(`crates/oak-worker/src/{worker,ipc}.rs`, self-contained, direct Rust
calls into oakrender); the ipc.h control-plane message serializers remain
unwrapped.
## Testing
The module crates are real dependencies, so `cargo test` links the same
rlibs the cdylib embeds; the dev-dependencies re-declare
`oakcommon`/`oakplugin` with their `test-stubs` features so the test
binaries keep the in-crate mocks (ffmpeg_bridge stub / render mocks):
The crate is cdylib-only (no rlib artifact), so integration tests cannot
link it as a crate; the former `tests/*.rs` moved into `src/test_support/`
and run as in-crate unit tests (pulled in from `src/lib.rs` under
`#[cfg(test)]`), addressing the modules through `crate::*`. The module
crates are real dependencies, so the test binary statically links the same
rlibs the cdylib embeds:
- `oaknode`/`oaktimeline`/`oaktask` are linked WITHOUT their `test-stubs`
features: their in-crate mocks would collide with the real oakundo rlib
in one test binary. Without test-stubs their real exports reference the
oaknode/oakundo/oakcommon C ABI symbols as link-time externs, which the
sibling crate rlibs provide; oaknode itself resolves cross-module
symbols at runtime with `dlsym(RTLD_DEFAULT)`.
- `tests/common/mod.rs` re-exports the facade's in-dylib
`oakcore_audioparams_*` accessors (M12 P5 folded them in) and
force-links the oakcommon XML writer/reader + the oakundo command
factory so the oaknode serializer's dlsym lookups resolve in every test
binary.
- `src/lib.rs`'s test-only `test_link` forces the oakrender/oaknode/
oaktimeline/oaktask rlibs into the lib unit-test binary (the always-on
`src/linkage.rs` anchors are `#[cfg(not(test))]`; they are what embeds
the module C ABIs in the cdylib for `cargo build`).
- `src/linkage.rs` (always-on) anchors the module rlibs into the cdylib
for `cargo build`; the test-only `test_link` module in `src/lib.rs`
forces the oakrender/oaknode/oaktimeline/oaktask rlibs into the lib
unit-test binary, and `src/test_support/common/mod.rs::force_link`
covers the test-support module (same symbol list, so the anchor paths
stay proven against the current module layouts).
- Tests that touch process-wide state (the audio manager, the undo stack,
the task manager) take a shared serialization lock instead of relying
on the process isolation the old integration tests had
(`test_support/common::with_manager`, the per-family `SERIAL` mutexes).
Families whose wrapped behavior requires the real module dylibs carry
`#[ignore]` tests with a documented reason; the smoke tests here exercise
the module crates' real implementations.
The smoke tests exercise the module crates' real implementations. Where a
wrapped family needs module behavior the crates do not implement yet, the
engine function is a documented stub with its reason (see `deferred.rs`).
```
cargo test # lib tests + integration families (undo, common, audio,
# plugin, codec, render, linkage, node, timeline, task)
cargo build # cdylib embeds the module C ABIs + the folded-in
# oakcore_audioparams_* accessors (no undefined imports)
cargo test # in-crate unit tests (undo, common, audio, codec,
# exporter, plugin, render, node, timeline, task,
# library/storage families)
cargo build # liboakengine cdylib embeds the module rlibs + the
# folded-in oakcore_audioparams_* accessors (no
# undefined imports)
```
## FFI discipline
+3 -2
View File
@@ -16,8 +16,9 @@
//! Build-time link configuration for the `liboakengine` cdylib.
//!
//! The dylib carries the module C ABIs itself (oakundo_*, oakcommon_*, ...
//! — see Cargo.toml). The `oakcore_audioparams_*` accessors the audio
//! The dylib carries the module crates themselves (their direct-Rust
//! code, kept in the link by `src/linkage.rs` — see Cargo.toml). The
//! `oakcore_audioparams_*` accessors the audio
//! paths read through used to be host-provided C++ liboakcore symbols,
//! left as runtime lookups via `-undefined,dynamic_lookup`; M12 P5
//! implemented them inside the dylib (src/stubs.rs, module `audio`), so
+29 -31
View File
@@ -14,24 +14,31 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! # oakengine — the `liboakengine` facade (Rust)
//! # oakengine — the `liboakengine` cdylib (plugin / external C ABI)
//!
//! Re-exports the frozen `oakengine_*` C ABI
//! (`engine/include/oakengine/*.h`) verbatim on top of the module C ABIs
//! (`include/<mod>/*.h`, implemented by the oakundo/oaknode/oaktimeline/
//! oakcodec/oakaudio/oakrender/oaktask/oakcommon/oakplugin crates). It is
//! the M9 §4 assembly layer: every module call crosses the module C ABI as
//! an `extern "C"` import (see [`bridge`]); the facade itself owns only
//! cross-cutting state (the process-wide undo stack and the open undo
//! group, see [`undo`]).
//! The frozen `oakengine_*` C ABI (`engine/include/oakengine/*.h`) as a
//! **pure cdylib** (M14 R4). This is the plugin / external-consumer layer:
//! OFX plugins and third-party embedders link `liboakengine` and call the
//! C ABI; the app, oak-cli and oak-worker do not use it anymore — they
//! link the module crates directly as Rust rlibs.
//!
//! Downward, every `oakengine_*` export is a direct Rust call into the
//! module crates (oakundo/oaknode/oaktimeline/oakcodec/oakaudio/oakrender/
//! oaktask/oakcommon/oakplugin/oakstorage/oakcore-rs) through [`stubs`]
//! (the rewired replacement for the deleted `bridge/`); the C ABI itself
//! stays frozen (only additive changes + major version bumps).
//! Cross-cutting state that used to live here (the process-wide undo stack,
//! the open undo group) has sunk into the modules (M14 R1:
//! [`oakundo::global`]); [`undo`] is a thin forward that adds the engine's
//! box/unbox, buf/size and error-code conventions.
//!
//! ## Handle mapping
//!
//! The engine headers' opaque pointers (`OakEngineNode*`, `OakEngineTrack*`,
//! ...) become thin newtype wrappers around module [`handle::CHandle`]
//! values (see [`handle`]). Each exported function keeps the exact
//! signature from the engine header; inside, it unboxes the module handle,
//! calls the module C ABI and boxes the result.
//! signature from the engine header; inside, it unboxes the module value,
//! calls the module's direct Rust API and boxes the result.
//!
//! ## FFI discipline
//!
@@ -42,29 +49,20 @@
//!
//! ## Testing
//!
//! The module crates are real dependencies (see Cargo.toml) and
//! [`linkage`] anchors them into every link of this crate, so the module
//! C ABIs are embedded in the `liboakengine` cdylib next to the facade's
//! own exports. `cargo test` links the same crates' rlibs (plus the
//! `test-stubs` feature union declared in the dev-dependencies, which
//! compiles the oakcommon/oakplugin in-crate mocks); `tests/linkage.rs`
//! additionally references every crate for the integration-test binaries
//! and `test_link` (below) covers the unit-test binary. Where a wrapped
//! family needs module behavior the crates do not implement yet, the
//! engine function is a documented stub and its test carries `#[ignore]`
//! with a reason (see README.md).
//! The crate is cdylib-only, so the former `tests/*.rs` integration tests
//! run as in-crate unit tests under `src/test_support/` (pulled in from
//! here under `#[cfg(test)]`; they address the crate's modules through
//! `crate::*`). The module crates are real dependencies (see Cargo.toml),
//! so the test binary statically links the same rlibs the cdylib embeds;
//! [`linkage`] anchors every crate into the cdylib link, and the test-only
//! [`test_link`] module does the same for the unit-test binary. Where a
//! wrapped family needs module behavior the crates do not implement yet,
//! the engine function is a documented stub with its reason (see
//! `deferred.rs` and README.md).
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
// Re-export the node crate so integration tests (and embedders) address
// the SAME compiled instance the facade uses: under `cargo test
// --workspace`, oaknode is built twice (oaknode's own dev-dependency
// enables oakcodec/test-stubs), and a test that mixes `oaknode::` direct
// imports with `oakengine::` re-exports gets two incompatible type
// instances (E0308 "multiple different versions of crate oaknode").
pub use oaknode;
pub mod audio;
pub mod codec;
pub mod common;
@@ -98,7 +96,7 @@ mod test_link {
// The lib's own unit-test binary must link the module crates' rlibs to
// satisfy the facade's imports that the unit tests compile in — e.g.
// the render family's oakrender display renderer (src/render.rs).
// The integration tests do the same through tests/common/mod.rs
// The in-crate tests do the same through test_support/common/mod.rs
// `force_link()`; this covers the `cargo test` unit-test binary.
#![allow(dead_code)]
fn force_link() -> usize {
+14 -12
View File
@@ -16,20 +16,22 @@
//! Linkage anchors — force the module crates' rlibs into every link.
//!
//! The facade talks to the modules exclusively through `extern "C"`
//! imports (src/bridge/), so rustc would otherwise consider the module
//! crates unused and prune their rlibs from the link. This module
//! references one `#[no_mangle]` export of every module crate (and
//! `oakcore-rs`) from a `#[used]` static, which (a) marks each crate as
//! used so its rlib reaches the linker and (b) keeps the anchor alive so
//! the referenced object files are pulled. For the `liboakengine` cdylib
//! this is what actually embeds the module C ABIs (oakundo_*,
//! oakcommon_*, ...) into the dylib next to the facade's own oakengine_*
//! exports.
//! The facade calls the module crates' direct Rust APIs (single-lib; the
//! deleted `src/bridge/` no longer exists), and most modules reach the
//! linker through those normal references. The anchors below reference one
//! direct-Rust symbol of every module crate (and `oakcore-rs`) from a
//! `#[used]` static, which (a) marks each crate as used so its rlib
//! reaches the linker even when the facade only touches it indirectly and
//! (b) keeps the anchor alive so the referenced object files are pulled.
//! For the `liboakengine` cdylib this is what embeds the module crates
//! next to the facade's own `oakengine_*` exports — including the
//! oakcommon XML/undo symbols oaknode's serializer resolves at runtime
//! via dlsym(RTLD_DEFAULT) (see the per-anchor comments in `force_link`).
//!
//! The per-crate symbol mirrors the test-force-link in
//! tests/common/mod.rs (same paths, same `as usize` cast idiom), so the
//! crate/module paths are proven against the current module layouts.
//! test_support/common/mod.rs (same paths, same `as usize` cast idiom),
//! so the crate/module paths are proven against the current module
//! layouts.
#![allow(dead_code)]
@@ -20,10 +20,11 @@
//! Two jobs:
//!
//! 1. **Force rustc to link every module crate's rlib** into the test
//! binary ([`force_link`]). The facade itself only references the
//! modules through `extern "C"` imports (see src/bridge), so rustc
//! would otherwise drop the dev-dependency rlibs from the link and
//! leave the imports undefined.
//! binary ([`force_link`]). The test-support files import the module
//! crates directly (single-lib; the deleted `src/bridge` no longer
//! exists), and the array doubles as a compile-time proof that the
//! anchor paths in `crates/oakengine/src/linkage.rs` match the current
//! module layouts.
//!
//! 2. **Re-export the folded-in `oakcore_audioparams_*` accessors** for
//! the former mock call sites (`common::oakcore_audioparams_*`). The
@@ -85,7 +85,7 @@ struct SerialGuard {
_task: std::sync::MutexGuard<'static, ()>,
/// The facade-wide undo-stack lock, so the `oakengine_project_new`
/// calls in these tests never race the it_undo / it_storage stack tests.
_stack: std::sync::MutexGuard<'static, ()>,
_stack: parking_lot::ReentrantMutexGuard<'static, ()>,
}
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
@@ -93,8 +93,7 @@ struct SerialGuard {
fn serial() -> SerialGuard {
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
.lock();
SerialGuard { _task, _stack }
}
@@ -81,7 +81,7 @@ fn journal_rows(db: &Path, uuid: &str) -> usize {
/// the storage-config lock for the whole body, then point the library at a
/// temp SQLite file.
fn with_library<R>(db: &Path, f: impl FnOnce() -> R) -> R {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _stack = GLOBAL_STACK_LOCK.lock();
let _config = common::STORAGE_CONFIG_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
@@ -394,7 +394,7 @@ fn export_then_import_round_trip() {
/// mutating call fails with E_STATE.
#[test]
fn disabled_backend_degrades_gracefully() {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _stack = GLOBAL_STACK_LOCK.lock();
let _off = common::storage_off_guard();
assert_eq!(list_json(), "[]", "no library configured reads as empty");
@@ -67,7 +67,7 @@ const MATH: &str = "org.olivevideoeditor.Olive.math";
/// the storage-config lock for the whole body, then point the write-through
/// backend at a temp library.
fn with_storage<R>(db: &Path, interval: i32, f: impl FnOnce() -> R) -> R {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _stack = GLOBAL_STACK_LOCK.lock();
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store = oakcommon::configstore::ConfigStore::instance();
store.set(Some("Storage"), "Backend", "sqlite");
@@ -80,7 +80,7 @@ fn with_storage<R>(db: &Path, interval: i32, f: impl FnOnce() -> R) -> R {
/// "off"`): projects bind to nothing and the undo stack stays untouched by
/// write-throughs.
fn with_storage_off<R>(f: impl FnOnce() -> R) -> R {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _stack = GLOBAL_STACK_LOCK.lock();
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
oakcommon::configstore::ConfigStore::instance().set(Some("Storage"), "Backend", "off");
f()
@@ -624,7 +624,7 @@ fn unwritable_library_records_last_error() {
#[test]
fn default_library_path_and_backend() {
common::force_link();
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _stack = GLOBAL_STACK_LOCK.lock();
let _config = common::STORAGE_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// The default path is a plain absolute `…/library.db`.
+2 -3
View File
@@ -97,7 +97,7 @@ pub(crate) struct SerialGuard {
/// The facade's process-wide undo-stack lock (it_undo's), so the
/// `oakengine_project_new` calls in these tests (which clear the stack)
/// never race the it_undo / it_storage stack tests.
_stack: std::sync::MutexGuard<'static, ()>,
_stack: parking_lot::ReentrantMutexGuard<'static, ()>,
}
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
@@ -105,8 +105,7 @@ pub(crate) struct SerialGuard {
pub(crate) fn serial() -> SerialGuard {
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
.lock();
SerialGuard { _task, _stack }
}
@@ -72,7 +72,7 @@ struct SerialGuard {
/// The [`SERIAL`] lock.
_task: std::sync::MutexGuard<'static, ()>,
/// The facade-wide undo-stack lock.
_stack: std::sync::MutexGuard<'static, ()>,
_stack: parking_lot::ReentrantMutexGuard<'static, ()>,
}
/// Take the [`SERIAL`] lock AND the global undo-stack lock, recovering
@@ -80,8 +80,7 @@ struct SerialGuard {
fn serial() -> SerialGuard {
let _task = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
.lock();
SerialGuard { _task, _stack }
}
+6 -5
View File
@@ -92,7 +92,8 @@ unsafe fn read_str(buf: *const c_char) -> String {
/// group cannot be shared, so each of those tests holds this lock for its
/// whole body. Public so the write-through tests (it_storage.rs), which
/// push commands on the same global stack, serialize on the SAME lock.
pub static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(());
pub static GLOBAL_STACK_LOCK: parking_lot::ReentrantMutex<()> =
parking_lot::ReentrantMutex::new(());
static LIFECYCLE_REDO: AtomicI32 = AtomicI32::new(0);
static LIFECYCLE_UNDO: AtomicI32 = AtomicI32::new(0);
@@ -570,7 +571,7 @@ fn free_contracts() {
/// group begin/end/abort lifecycle.
#[test]
fn undo_stack_integration() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
let _lock = GLOBAL_STACK_LOCK.lock();
common::force_link();
// --- Baseline: clear() resets to the single "New/Open Project" row.
@@ -834,7 +835,7 @@ fn undo_stack_integration() {
/// label") and the facade docs.
#[test]
fn null_name_push_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
let _lock = GLOBAL_STACK_LOCK.lock();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
@@ -866,7 +867,7 @@ fn null_name_push_repro() {
/// is safe.
#[test]
fn null_name_group_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
let _lock = GLOBAL_STACK_LOCK.lock();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
@@ -905,7 +906,7 @@ unsafe extern "C" fn abort_undo_cb(_ud: *mut c_void) {
/// satisfied by a leftover value from an earlier jump.)
#[test]
fn group_abort_undoes_children_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
let _lock = GLOBAL_STACK_LOCK.lock();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
@@ -108,6 +108,7 @@ unsafe fn find_node(project: *mut crate::handle::OakEngineProject, id: &str) ->
/// process-wide (the same serialization the undo family uses).
#[test]
fn project_node_keyframe_lifecycle() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
common::force_link();
let _ = force_oakundo_command_link();
// The undo commands below would bind the project to the default user
@@ -399,6 +400,7 @@ fn project_node_keyframe_lifecycle() {
/// NULL handles yield -1 and out-of-range indexes yield -4.
#[test]
fn node_failure_paths() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
common::force_link();
// NULL node → OAKENGINE_E_INVALID (-1).
@@ -437,6 +439,7 @@ fn node_failure_paths() {
/// Footage probe/import/borrow failure paths (no media required).
#[test]
fn footage_failure_paths() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
common::force_link();
// Probing a nonexistent path → NULL + a non-empty last error.
+32 -1
View File
@@ -19,11 +19,17 @@
//! tests, so the manager/cacher families exercise the module's STATE
//! error path and the renderer/color families exercise the NULL/invalid
//! argument paths (real rendering needs the deferred node family plus an
//! initialized render manager).
//! initialized render manager). The empty-sequence repro at the bottom
//! brings the process-global manager up for its run and shuts it back
//! down, restoring this invariant for the tests that follow it; the two
//! manager-touching tests serialize on [`SERIAL`] so the repro never
//! overlaps `render_manager_not_initialized` (the `it_task`/`it_export`
//! pattern).
use super::common;
use std::ffi::{c_char, c_double};
use std::sync::{Mutex, MutexGuard};
use crate::render::{
oakengine_color_last_error, oakengine_color_manager_get_config_filename,
@@ -38,10 +44,23 @@ use crate::render::{
OakColorTransformPod,
};
/// Serialize the manager-touching render tests (the pattern in
/// `it_task`/`it_export`). The empty-sequence repro brings the
/// process-global render manager up (and back down) inside its run;
/// without the lock it overlaps `render_manager_not_initialized`, whose
/// STATE paths then see an initialized manager.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
/// Render manager state without initialization: the module reports its
/// STATE error, passed through untranslated (-70002).
#[test]
fn render_manager_not_initialized() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
let _g = serial();
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) },
-70002
@@ -62,6 +81,7 @@ fn render_manager_not_initialized() {
/// Renderer lifecycle: NULL sequence is rejected; mode validation.
#[test]
fn renderer_lifecycle() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
// NULL seq → NULL renderer.
let r = unsafe {
oakengine_renderer_create(
@@ -92,6 +112,7 @@ fn renderer_lifecycle() {
/// Frame accessors on NULL / empty handles report zero/NULL safely.
#[test]
fn frame_accessors_null_safe() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
assert_eq!(unsafe { oakengine_frame_width(std::ptr::null()) }, 0);
assert_eq!(unsafe { oakengine_frame_height(std::ptr::null()) }, 0);
assert_eq!(
@@ -107,6 +128,7 @@ fn frame_accessors_null_safe() {
/// NULL; freeing is safe either way.
#[test]
fn color_processor_lifecycle() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
// NULL input → NULL.
let p = unsafe {
oakengine_color_processor_create(std::ptr::null(), std::ptr::null(), std::ptr::null(), 0)
@@ -157,6 +179,7 @@ fn color_processor_lifecycle() {
/// reports STATE; the last-error string starts empty.
#[test]
fn color_manager_and_error() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
let mut buf = [0 as c_char; 64];
let rc = unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), buf.as_mut_ptr(), 64)
@@ -170,6 +193,7 @@ fn color_manager_and_error() {
/// LUT library stubs report the documented neutral values.
#[test]
fn lut_library_stubs() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
assert_eq!(unsafe { oakengine_lut_directory_count() }, 0);
assert_eq!(
unsafe { oakengine_lut_set_directories(std::ptr::null(), 0) },
@@ -180,6 +204,8 @@ fn lut_library_stubs() {
// repro: render_audio on an empty sequence (playback tick on an empty timeline).
#[test]
fn render_audio_empty_sequence_no_crash() {
let _stack = super::it_undo::GLOBAL_STACK_LOCK.lock();
let _g = serial();
super::common::force_link();
unsafe {
assert_eq!(crate::render::oakengine_render_manager_init(), 0);
@@ -200,5 +226,10 @@ fn render_audio_empty_sequence_no_crash() {
}
crate::render::oakengine_renderer_free(r);
crate::node::oakengine_project_free(project);
// The repro initialized the process-global render manager; tear it
// down so the tests after this one keep the module header's
// documented "manager not initialized" contract (the STATE paths
// asserted by `render_manager_not_initialized`).
assert_eq!(crate::render::oakengine_render_manager_shutdown(), 0);
}
}
+1 -2
View File
@@ -157,8 +157,7 @@ fn undo_stack_lifecycle() {
// write-through tests (the facade's stack is process-wide): without it
// a concurrent test's pushes break the exact-count assertions below.
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
.lock();
// Reset to a clean "New/Open Project" base row.
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
+5 -2
View File
@@ -3,8 +3,11 @@
This document describes how to build Oak Video Editor from source on Windows, Linux, and macOS.
> **Note (2026):** Oak is now a Rust workspace; `cargo build` at the
> repository root produces the app, the CLI, the worker and
> `liboakengine`. The CMake instructions below are kept for historical
> repository root produces the app, the CLI and the worker
> (`liboakengine` — the plugin/external-consumer cdylib — is not a
> default member, M14 R4; build it explicitly with
> `cargo build -p oakengine`). The CMake instructions below are kept for
> historical
> reference only. Rust dependencies are pulled from crates.io; the only
> native libraries still needed are FFmpeg (see the next section),
> OpenColorIO (optional; `ocio-sys` builds a stub without it) and a
+1 -1
View File
@@ -45,7 +45,7 @@
| R1 | 胶水下沉:oakundo 全局栈+通知、oakstorage 会话管理器、oaktask 任务面补齐、oaknode ops 组合函数 | 各模块自带测试绿;facade 改为转发下沉版(行为不变,全量绿) |
| R2 | oak-cli + oak-worker 切换(小,先蹚路) | 两 crate 零 oakengine 依赖,测试绿 |
| R3 | app 切换:src/oakui/ffi.rs、host_syms.rs 删除,real.rs 全量改 Rust 调用;AppEngine trait 不动(Mock 保留) | `cargo test`(根 crate)全绿;真机冒烟(导入/播放/编辑/导出) |
| R4 | oakengine 转纯 cdylibworkspace default-members 移除 rlib 使用点清零)、根 build.rs 链接逻辑删除、CD 去掉 app 内嵌 dylib | CI 绿;dmg 体积显著缩小(不再嵌 37-56MB dylib |
| R4 | oakengine 转纯 cdylibworkspace default-members 移除 rlib 使用点清零)、根 build.rs 链接逻辑删除、CD 去掉 app 内嵌 dylib**已完成,2026-08**crate-type 已是 cdylib-only、零 Rust 依赖方、cd.yml 去掉 embed 步骤与 `-p oakengine` 预构建) | CI 绿;dmg 体积显著缩小(不再嵌 37-56MB dylib |
| R5 | M13 遗留的 CHandle 内部清除逐模块完成(bridge 已删,剩 handle.rs 与模块内 CHandle 传参) | 模块内部无 CHandle 传参;C ABI 导出层(oakengine)独占 CHandle |
依赖:R1→R2→R3→R4R5 与 R2-R4 可并行。