Skip to content

Commit 3f65530

Browse files
proggeramlugRalph Küpper
andauthored
fix(ui): root persistent JavaScript callbacks; complete the C-ABI FFI layer (#8721)
Lands #8713, #8704 and #8717. #8713 (closes #8701) fixes the systemic gap I filed while auditing #8699: every perry-ui-* crate cached JS callbacks as raw NaN-boxed f64 in thread-local side tables, and NO UI crate registered a root scanner. A stored callback is a heap pointer held across collections -- unmarked, so a live listener could be reclaimed, and unrewritten, so it could dangle after an evacuating minor and surface cycles later as "value is not a function". It registers one mutable root scanner per UI crate and for the shared dispatcher, and graduates all 474 census candidates: the frontier drops 628 -> 158 while audited holders rise 71 -> 280. The specific holder I named in #8701, perry-ui-ios adaptive_layout LISTENERS, is now scanner-covered; its sibling LAST_SNAPSHOT is correctly classified not_a_gc_pointer (native viewport dimensions, no JS value). The census got STRONGER, not weaker -- checked, because graduating 468 holders by relaxing the checker would look identical on the surface. Its self-test goes from 89 planted declarations / 0 inventory entries checked to 90 / 280. main's self-test validated none of the ledger; this one validates all of it. #8704 completes the bun:ffi / node:ffi C ABI. The raw-handle debt that held it is resolved: bare reads are 913 against a baseline of 913, with the baseline LOWERED from 918 -- a ratchet tightening, not a waiver. #8717 renders Windows PdfView pages natively through Windows.Data.Pdf. Two mechanical fixes on top: a not_a_gc_pointer verdict for #8717's COMPLETED render table (keyed by a monotonic request id, holding only owned PNG bytes or an error String), a changelog fragment for #8717, and deletion of three ledger entries that go stale once #8704's scanner widens call-graph coverage. No version bump. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 850e6f1 commit 3f65530

236 files changed

Lines changed: 6113 additions & 2475 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
category: Runtime
3+
title: Complete Bun and Node C-ABI FFI support
4+
---
5+
6+
Perry now supports typed scalar C-ABI calls through `bun:ffi`, including stack
7+
arguments, pinned pointers, zero-copy native memory views, scalar reads, and
8+
rooted same-thread or threadsafe callbacks. A Node 26-compatible `node:ffi`
9+
adapter lets OpenTUI/Yoga and other native wrappers load their upstream shared
10+
libraries without source changes, and the real `bun-pty` shell roundtrip is
11+
covered end to end.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
### Fixed
2+
3+
Root persistent `perry/ui` JavaScript callbacks and state values across garbage
4+
collections on every native UI backend, including callbacks retained by native
5+
timer, hotkey, and focus-event closures.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Replaced the Windows `PdfView` STATIC-label placeholder and its text-scanned page count with a real `Windows.Data.Pdf` render path: the active page is rendered to PNG and painted in the Win32 control with GDI+, preserving aspect ratio and zoom. Navigation and zoom updates render on MTA workers with stale completions discarded, and widget state is released on destruction. Adds page-size and real WinRT decode/render coverage, plus Windows backend documentation.

crates/perry-api-manifest/src/entries.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ pub const NATIVE_MODULES: &[&str] = &[
5858
// #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier
5959
// (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`.
6060
"bun:ffi",
61+
"ffi", // node:ffi (the node: prefix is normalized away)
6162
"bun:sqlite", // Bun facade over Perry's native SQLite engine
6263
"node-cron", // cron-style scheduler (npm node-cron; aliases `cron`)
6364
"nodemailer", // SMTP email sending
@@ -243,6 +244,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[
243244
"buffer",
244245
// #6562: bun:ffi is implemented entirely in perry-runtime.
245246
"bun:ffi",
247+
"ffi",
246248
"assert",
247249
"assert/strict",
248250
"test",

crates/perry-api-manifest/src/entries/part_1.rs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,8 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[
206206
// bun:ffi (#6562). `FFIType` and `suffix` are
207207
// constants; symbol-table call stubs live on the object `dlopen`
208208
// returns, so the module surface itself is small. The later-stage
209-
// exports (linkSymbols / CFunction / viewSource / read) are
210-
// declared and throw a descriptive
211-
// ERR_NOT_IMPLEMENTED at runtime.
209+
// exports (linkSymbols / CFunction / viewSource / read) share the same
210+
// scalar ABI and pinned-memory implementation.
212211
method("bun:ffi", "dlopen", false, None),
213212
method("bun:ffi", "ptr", false, None),
214213
method("bun:ffi", "CString", false, None),
@@ -217,18 +216,18 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[
217216
method("bun:ffi", "toArrayBuffer", false, None),
218217
method("bun:ffi", "toBuffer", false, None),
219218
method("bun:ffi", "JSCallback", false, None),
220-
// Remaining surface: declared so feature-probes get a clear error rather
221-
// than `undefined is not a function`, but NOT implemented yet — each
222-
// throws at runtime. Marked `.stub_note` so the generated `.d.ts` /
223-
// `reference.md` say so instead of reading as usable APIs (#6562).
224-
method("bun:ffi", "CFunction", false, None)
225-
.stub_note("stage 3 — not yet implemented, throws at runtime (#6562)"),
226-
method("bun:ffi", "linkSymbols", false, None)
227-
.stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"),
228-
method("bun:ffi", "viewSource", false, None)
229-
.stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"),
230-
method("bun:ffi", "read", false, None)
231-
.stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"),
219+
method("bun:ffi", "CFunction", false, None),
220+
method("bun:ffi", "linkSymbols", false, None),
221+
method("bun:ffi", "viewSource", false, None),
222+
property("bun:ffi", "read"),
223+
// node:ffi compatibility surface (Node 26), consumed by OpenTUI's Node
224+
// adapter. Callback registration lives on the library returned by dlopen.
225+
method("ffi", "dlopen", false, None),
226+
method("ffi", "getRawPointer", false, None),
227+
method("ffi", "toArrayBuffer", false, None),
228+
method("ffi", "toBuffer", false, None),
229+
method("ffi", "toString", false, None),
230+
property("ffi", "suffix"),
232231
// bun:sqlite (#8510) shares node:sqlite's rusqlite handles while keeping
233232
// Bun's public constructor and statement vocabulary.
234233
class("bun:sqlite", "Database"),

crates/perry-api-manifest/tests/stub_inventory.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,6 @@ fn stub_inventory_matches_known_clusters() {
9393
// event-loop refcount), mongodb.findOne (parsed document),
9494
// exponential-backoff options (honored, incl. retry predicate).
9595
("#4917", 9),
96-
// #6562 (bun:ffi) — the remaining FFI surface is declared so
97-
// feature probes get a clear error, but throws at runtime until the
98-
// later stages land: CFunction, linkSymbols, viewSource, read.
99-
("#6562", 4),
10096
];
10197
let expected_map: BTreeMap<String, usize> =
10298
expected.iter().map(|(k, v)| (k.to_string(), *v)).collect();
@@ -123,8 +119,6 @@ fn stubs_only_appear_in_allowlisted_modules() {
123119
"exponential-backoff",
124120
"inspector",
125121
"repl",
126-
// #6562: bun:ffi later-stage exports are declared-but-throwing stubs.
127-
"bun:ffi",
128122
];
129123
for e in iter_entries().filter(|e| e.stub) {
130124
assert!(

crates/perry-audio-miniaudio/src/lib.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
use libc::{c_char, c_float, c_int, c_uint, c_void};
2121
use std::cell::RefCell;
2222
use std::ffi::CString;
23-
use std::sync::Mutex;
23+
use std::sync::{Mutex, Once};
2424

2525
use perry_ffi::copy_string_from_raw as str_from_header;
2626

@@ -227,6 +227,8 @@ struct Fade {
227227
then_stop: bool,
228228
}
229229

230+
static GC_SCANNER_REGISTERED: Once = Once::new();
231+
230232
thread_local! {
231233
static ENGINE: RefCell<Option<MaBox<MA_ENGINE_SIZE>>> = RefCell::new(None);
232234
static SOUNDS: RefCell<Vec<Option<SoundEntry>>> = RefCell::new(Vec::new());
@@ -238,6 +240,32 @@ thread_local! {
238240
static PENDING_LOADED: RefCell<Vec<usize>> = RefCell::new(Vec::new());
239241
}
240242

243+
fn ensure_gc_scanner_registered() {
244+
GC_SCANNER_REGISTERED.call_once(|| {
245+
perry_ffi::gc_register_mutable_root_scanner_named(
246+
"perry-audio-miniaudio",
247+
scan_miniaudio_gc_roots,
248+
);
249+
});
250+
}
251+
252+
fn scan_miniaudio_gc_roots(visitor: &mut perry_ffi::GcRootVisitor<'_>) {
253+
SOUNDS.with(|sounds| {
254+
for sound in sounds.borrow_mut().iter_mut().flatten() {
255+
if let Some(callback) = sound.on_loaded.as_mut() {
256+
visitor.visit_nanbox_f64_slot(callback);
257+
}
258+
}
259+
});
260+
VOICES.with(|voices| {
261+
for voice in voices.borrow_mut().iter_mut().flatten() {
262+
if let Some(callback) = voice.on_ended.as_mut() {
263+
visitor.visit_nanbox_f64_slot(callback);
264+
}
265+
}
266+
});
267+
}
268+
241269
/// Voice indices whose miniaudio end_callback fired on the audio thread.
242270
/// We can't touch thread-locals from there; main-thread `drain_*` pulls
243271
/// these into PENDING_ENDED on every hot-path entry point.
@@ -460,6 +488,7 @@ pub extern "C" fn perry_audio_unload(sound: f64) {
460488

461489
#[no_mangle]
462490
pub extern "C" fn perry_audio_on_loaded(sound: f64, callback: f64) {
491+
ensure_gc_scanner_registered();
463492
let idx = match classify(sound) {
464493
HandleKind::Sound(i) => i,
465494
_ => return,
@@ -1174,6 +1203,7 @@ pub extern "C" fn perry_audio_get_position(playback: f64) -> f64 {
11741203

11751204
#[no_mangle]
11761205
pub extern "C" fn perry_audio_on_ended(playback: f64, callback: f64) {
1206+
ensure_gc_scanner_registered();
11771207
let idx = match classify(playback) {
11781208
HandleKind::Playback(i) => i,
11791209
_ => return,

crates/perry-codegen/src/codegen/entry.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,9 @@ pub(super) fn compile_module_entry(
11661166
"0".to_string()
11671167
};
11681168
let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]);
1169+
let has_ffi_callbacks =
1170+
ctx.block()
1171+
.call(I32, "js_bun_ffi_has_active_threadsafe_callbacks", &[]);
11691172
// #591: TASK_QUEUE may carry a pending `.then` continuation
11701173
// that was queued by `js_run_stdlib_pump`'s resolution path
11711174
// in the SAME body iteration that already drained the inflight
@@ -1175,6 +1178,7 @@ pub(super) fn compile_module_entry(
11751178
let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]);
11761179
let any1 = ctx.block().or(I32, &has_timers, &has_callbacks);
11771180
let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib);
1181+
let any2 = ctx.block().or(I32, &any2, &has_ffi_callbacks);
11781182
let any3 = ctx.block().or(I32, &any1, &any2);
11791183
let any4 = ctx.block().or(I32, &any3, &has_cron);
11801184
let any = ctx.block().or(I32, &any4, &has_microtasks);

crates/perry-codegen/src/expr/fs_await.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
222222
let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]);
223223
let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]);
224224
let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]);
225+
let has_ffi_callbacks =
226+
ctx.block()
227+
.call(I32, "js_bun_ffi_has_active_threadsafe_callbacks", &[]);
225228
let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]);
226229
let any1 = ctx.block().or(I32, &has_timers, &has_callbacks);
227230
let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib);
231+
let any2 = ctx.block().or(I32, &any2, &has_ffi_callbacks);
228232
let any3 = ctx.block().or(I32, &any1, &any2);
229233
let any = ctx.block().or(I32, &any3, &has_microtasks);
230234
let no_refed_work = ctx.block().icmp_eq(I32, &any, "0");

0 commit comments

Comments
 (0)