diff --git a/changelog.d/8595-entry-outline-default.md b/changelog.d/8595-entry-outline-default.md new file mode 100644 index 0000000000..b332addeae --- /dev/null +++ b/changelog.d/8595-entry-outline-default.md @@ -0,0 +1 @@ +perf(codegen): finish structured module-entry outlining (#8595). Entry bodies with at least 1,000 top-level HIR statements or 4,000 estimated safepoints now split automatically into ordered functions capped at roughly 200 statements or 1,000 safepoints (`PERRY_OUTLINE_ENTRY=0` disables and `=1` forces the transform). Original declarations move unchanged, bindings that need cross-function storage are promoted to rooted module globals, and declaration/export/const/static-field/early-`process.env` scans reconstruct the logical source-order entry stream. Exports, Script `globalThis` reflection, and structured control flow are supported; top-level await and module-level TDZ preallocation remain fail-safe exclusions. This bounds per-function RS4GC fan-out, instruction selection, optimization, and register allocation without changing the requested optimization level. diff --git a/changelog.d/8596-transitive-leaf.md b/changelog.d/8596-transitive-leaf.md new file mode 100644 index 0000000000..12a749db21 --- /dev/null +++ b/changelog.d/8596-transitive-leaf.md @@ -0,0 +1 @@ +perf(gc): reduce native-root safepoint density with a whole-module Perry-GC effect closure (#8596). Direct calls to generated functions are now marked `gc-leaf-function` when every transitive path stays within proven non-collecting runtime helpers and generated callees; pure recursive SCCs are supported. Allocation/poll paths, indirect calls, unknown externals, and cross-module calls remain statepoints. The proof and annotations are shared by whole-module text emission, split codegen units, and native LLVM construction (including `invoke` inside `try`). Shadow-frame bookkeeping helpers are also classified non-collecting; their only allocation is the raw Rust shadow-buffer `Vec`, which cannot trigger Perry GC. This is the sound polling-style reduction available with LLVM statepoints: a caller edge whose callee may collect must retain its relocation map so moving GC can rewrite the suspended caller frame. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 20b4a2f5a0..1e9c66a9ae 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -132,6 +132,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "perry/tui", // terminal-UI framework "perry/yoga", // Yoga flexbox layout "perry/ui", // native UI (AppKit/UIKit/Win32/GTK4/…) + "perry/ios", // iOS-only UIKit/Foundation Models APIs "perry/system", // OS integration (keychain, notifications, …) "perry/plugin", // compile-time plugin surface "perry/widget", // home-screen widgets (WidgetKit/Glance) @@ -265,6 +266,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ // (registry + fs interception); no perry-stdlib surface needed. "perry", "perry/ui", + "perry/ios", "perry/system", "perry/widget", "perry/i18n", diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 010140382d..e54672ee18 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -593,6 +593,14 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("perry/media", "onTimeUpdate", false, None), method("perry/media", "setNowPlaying", false, None), method("perry/media", "destroy", false, None), + // --- perry/ios (issue #5536) — auto-derivable from PERRY_IOS_TABLE. --- + method("perry/ios", "getLayoutEnvironment", false, None), + method("perry/ios", "onLayoutChange", false, None), + method("perry/ios", "offLayoutChange", false, None), + method("perry/ios", "foundationModelAvailability", false, None), + method("perry/ios", "createLanguageModelSession", false, None), + method("perry/ios", "respond", false, None), + method("perry/ios", "destroyLanguageModelSession", false, None), // --- perry/audio (issue #1867) — auto-derivable from PERRY_AUDIO_TABLE. --- method("perry/audio", "loadSound", false, None), method("perry/audio", "unload", false, None), diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index a4d301c8df..550b0bad71 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1831,7 +1831,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // name (`const bar = function namedBar(){}` ⇒ `"namedBar"`). let mut named_inline_closure_ids: std::collections::HashSet = std::collections::HashSet::new(); - for stmt in &hir.init { + for stmt in super::entry_outline::logical_entry_stmts(hir) { if let perry_hir::Stmt::Let { name, init, .. } = stmt { if name.is_empty() || name.starts_with('_') { continue; diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index df23478c13..d478aae49b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -122,7 +122,7 @@ fn emit_plugin_abi_shim(llmod: &mut LlModule, hir: &HirModule, module_prefix: &s /// (function(){ ... })()`), which is where the wrapped entry's top-level /// statements live. Assignments nested in conditionals or inner functions are /// deliberately skipped — those run conditionally/lazily, exactly as in Node. -fn collect_entry_env_literals(init: &[perry_hir::Stmt]) -> Vec<(String, String)> { +fn collect_entry_env_literals(hir: &HirModule) -> Vec<(String, String)> { use perry_hir::{Expr, Stmt}; fn record(expr: &Expr, out: &mut Vec<(String, String)>) { @@ -176,7 +176,9 @@ fn collect_entry_env_literals(init: &[perry_hir::Stmt]) -> Vec<(String, String)> } let mut out = Vec::new(); - scan(init, &mut out, 0); + for stmt in super::entry_outline::logical_entry_stmts(hir) { + scan(std::slice::from_ref(stmt), &mut out, 0); + } out } @@ -620,7 +622,7 @@ pub(super) fn compile_module_entry( // `collect_entry_env_literals`. The "NODE_ENV"/"production" string // handles are interned here and populated by the strings-init call // above (the entry body also references them, so they share slots). - for (name, value) in collect_entry_env_literals(&hir.init) { + for (name, value) in collect_entry_env_literals(hir) { let name_idx = strings.intern(&name); let value_idx = strings.intern(&value); let name_global = format!("@{}", strings.entry(name_idx).handle_global); diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs index ac7aa8a42c..8d7e5279e0 100644 --- a/crates/perry-codegen/src/codegen/entry_outline.rs +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -1,4 +1,4 @@ -//! Module-entry outlining — analysis + gate (#8595, first increment). +//! Structured module-entry outlining (#8595). //! //! The module top level is lowered into a single LLVM function (`@main` / //! `perry_module_init`). For a large minified bundle that one function is @@ -7,18 +7,17 @@ //! (relocation fan-out, #8583), instruction selection (#4880), and register //! allocation. The fix is to outline the entry body into many small functions. //! -//! This module is the **analysis half only** — it computes how the entry body -//! WOULD chunk and which top-level `let`s cross a chunk boundary (and therefore -//! must be globalized so the chunks can share them), and reports it. It does -//! **not** transform anything yet: the transform is the correctness-critical -//! part (eval order, TDZ, hoisting, top-level await) and lands separately once -//! it can be validated end-to-end. The reusable pieces here — the chunk -//! boundary rule and the cross-chunk reference set — are exactly what that -//! transform will consume to decide chunk boundaries and drive globalization. +//! Oversized entry bodies are split at top-level statement boundaries into +//! ordinary HIR functions. The original statements move unchanged, and calls +//! to the chunks remain in the original order. Codegen's module-global pass +//! recognises declarations in these compiler-owned chunks as module bindings, +//! so a declaration still executes at its source position while references +//! from another chunk share the same rooted storage. //! -//! Nothing here changes codegen output. `PERRY_OUTLINE_ENTRY_REPORT=1` prints -//! the analysis; the transform gate `PERRY_OUTLINE_ENTRY` exists but is inert -//! until the transform lands. +//! Outlining is automatic only for very large bodies. `PERRY_OUTLINE_ENTRY=1` +//! forces it for testing and measurement; `=0` disables it. Top-level await +//! and a module-level TDZ preallocation remain fail-safe exclusions because a +//! raw module-global load cannot yet perform the checked TDZ-box read. use std::collections::HashSet; @@ -28,10 +27,26 @@ use crate::collectors::{collect_let_ids, collect_ref_ids_in_stmts}; /// Default target number of top-level statements per outlined chunk. Chosen so /// a chunk's live-root × safepoint product stays well under the RS4GC fan-out -/// regime (#8583); tuned with the transform, so it is only a reporting knob -/// today. Overridable with `PERRY_OUTLINE_ENTRY_CHUNK_STMTS`. +/// regime (#8583). The independent safepoint budget below can flush sooner. +/// Overridable with `PERRY_OUTLINE_ENTRY_CHUNK_STMTS`. const DEFAULT_CHUNK_STMTS: usize = 200; +/// Ordinary modules are deliberately left byte-for-byte unchanged. The +/// production pathology has tens of thousands of top-level HIR statements; +/// 1,000 is low enough to catch it while keeping normal source modules out. +const DEFAULT_AUTO_MIN_STMTS: usize = 1_000; + +/// Call-like expressions are the dominant source of pointer temporaries and +/// statepoints. A generated entry with fewer top-level statements can still be +/// pathological, so both automatic admission and chunk flushing have a +/// safepoint budget. +const DEFAULT_CHUNK_SAFEPOINTS: usize = 1_000; +const DEFAULT_AUTO_MIN_SAFEPOINTS: usize = 4_000; + +/// Compiler-owned name prefix used to distinguish outlined entry functions +/// from source functions when reconstructing the logical top-level stream. +const ENTRY_CHUNK_PREFIX: &str = "__perry_entry_chunk_"; + fn target_chunk_stmts() -> usize { std::env::var("PERRY_OUTLINE_ENTRY_CHUNK_STMTS") .ok() @@ -40,14 +55,28 @@ fn target_chunk_stmts() -> usize { .unwrap_or(DEFAULT_CHUNK_STMTS) } -/// Whether the entry-outlining TRANSFORM is enabled. Inert in this increment -/// (no transform exists yet); present so the transform can gate on it without a -/// second flag churn. `PERRY_OUTLINE_ENTRY=1`/`on`/`true` turns it on. -pub(crate) fn entry_outlining_enabled() -> bool { - matches!( - std::env::var("PERRY_OUTLINE_ENTRY").as_deref(), - Ok("1") | Ok("on") | Ok("true") - ) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutlineMode { + Auto, + Forced, + Disabled, +} + +fn outline_mode_from_env(value: Option<&str>) -> OutlineMode { + match value { + Some("1" | "on" | "true") => OutlineMode::Forced, + Some("0" | "off" | "false") => OutlineMode::Disabled, + _ => OutlineMode::Auto, + } +} + +fn outline_mode() -> OutlineMode { + let value = std::env::var("PERRY_OUTLINE_ENTRY").ok(); + outline_mode_from_env(value.as_deref()) +} + +fn meets_automatic_size_threshold(stmt_count: usize, safepoint_count: usize) -> bool { + stmt_count >= DEFAULT_AUTO_MIN_STMTS || safepoint_count >= DEFAULT_AUTO_MIN_SAFEPOINTS } fn report_requested() -> bool { @@ -83,6 +112,144 @@ impl EntryOutlineAnalysis { } } +pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { + function.name.starts_with(ENTRY_CHUNK_PREFIX) + && function.params.is_empty() + && matches!(function.return_type, perry_hir::types::Type::Void) + && !function.is_async + && !function.is_generator + && !function.is_exported +} + +/// Reconstruct the source-order module-entry statement stream after outlining. +/// +/// Several codegen analyses intentionally inspect module declarations rather +/// than ordinary function bodies (exported closure signatures, const folding, +/// static-field deduplication, and early `process.env` assignments). Replacing +/// a range with a chunk call must not hide those original statements from the +/// analyses. Non-chunk calls and all inline statements are returned unchanged. +pub fn logical_entry_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { + let chunks: std::collections::HashMap = hir + .functions + .iter() + .filter(|function| is_entry_chunk(function)) + .map(|function| (function.id, function)) + .collect(); + let mut logical = Vec::new(); + for stmt in &hir.init { + let chunk = match stmt { + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() => + { + match callee.as_ref() { + perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), + _ => None, + } + } + _ => None, + }; + if let Some(chunk) = chunk { + logical.extend(chunk.body.iter()); + } else { + logical.push(stmt); + } + } + logical +} + +/// Moved declarations whose storage crosses a generated-function boundary. +/// +/// A declaration used only inside its defining chunk remains a cheap local. +/// References from another chunk or an inline entry statement require a rooted +/// module global. Re-declarations split across chunks share storage too. +/// Module-level preallocated boxes are also promoted: the prealloc statement +/// remains in `hir.init`, so a function-local box would otherwise be a +/// different cell from the declaration moved into the chunk. +pub(crate) fn outlined_entry_global_let_ids(hir: &HirModule) -> HashSet { + let chunks: Vec<&perry_hir::Function> = hir + .functions + .iter() + .filter(|function| is_entry_chunk(function)) + .collect(); + let mut definer: std::collections::HashMap = std::collections::HashMap::new(); + let mut globals = HashSet::new(); + + // Keep this in lock-step with `module_globals_emit::collect_init_lets`: + // destructuring declarations can be wrapped in iterator-cleanup `Try` + // scaffolding while still representing module bindings. + fn record_definers( + stmts: &[perry_hir::Stmt], + function_id: u32, + definer: &mut std::collections::HashMap, + globals: &mut HashSet, + ) { + for stmt in stmts { + match stmt { + perry_hir::Stmt::Let { id, .. } => { + if definer + .insert(*id, function_id) + .is_some_and(|prior| prior != function_id) + { + globals.insert(*id); + } + } + perry_hir::Stmt::Try { + body, + catch, + finally, + } => { + record_definers(body, function_id, definer, globals); + if let Some(catch) = catch { + record_definers(&catch.body, function_id, definer, globals); + } + if let Some(finally) = finally { + record_definers(finally, function_id, definer, globals); + } + } + _ => {} + } + } + } + + for function in &chunks { + record_definers(&function.body, function.id, &mut definer, &mut globals); + } + + for function in &chunks { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(&function.body, &mut refs); + for id in refs { + if definer + .get(&id) + .is_some_and(|defining_function| *defining_function != function.id) + { + globals.insert(id); + } + } + } + + let chunk_ids: HashSet = chunks.iter().map(|function| function.id).collect(); + for stmt in &hir.init { + match stmt { + perry_hir::Stmt::PreallocateBoxes(ids) => { + globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); + } + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() + && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => + { + // The compiler-owned call itself carries no module-local use. + } + _ => { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + } + } + } + globals +} + /// Chunk the top-level statement list into contiguous ranges of /// `target`-ish statements. Boundaries fall ONLY between top-level statements, /// never inside a compound statement, so a top-level `if`/`for`/`try` (and all @@ -92,10 +259,11 @@ fn chunk_ranges(total: usize, target: usize) -> Vec<(usize, usize)> { if total == 0 { return Vec::new(); } + let target = target.max(1); let mut ranges = Vec::new(); let mut start = 0; while start < total { - let end = (start + target).min(total); + let end = start.saturating_add(target).min(total); ranges.push((start, end)); start = end; } @@ -113,14 +281,19 @@ fn analyze_entry_outlining_with_target(hir: &HirModule, target: usize) -> EntryO let stmts = &hir.init; let total_stmts = stmts.len(); let ranges = chunk_ranges(total_stmts, target); - let chunk_count = ranges.len(); + let chunk_count = count_prospective_chunks(stmts, target); - // A top-level await splits the init across an async suspension; chunking - // across it is a distinct, harder transform, so such bodies are gated out - // initially. (Other gates — Script-scope `this`, generators — are added - // alongside the transform that needs them.) + // A top-level await splits init across an async suspension. A module-level + // TDZ preallocation needs checked global loads, which module globals do not + // provide yet. Both cases stay on the original lowering rather than + // accepting a semantic approximation. let gated_out = if hir.has_top_level_await { Some("top-level await") + } else if stmts + .iter() + .any(|stmt| matches!(stmt, perry_hir::Stmt::PreallocateTdzBoxes(_))) + { + Some("module-level TDZ preallocation") } else { None }; @@ -174,13 +347,11 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { return; } let a = analyze_entry_outlining(hir); - // When the transform is enabled it runs earlier (in the HIR phase, see - // `outline_entry_module`), so by the time codegen calls this the body is - // already rewritten — the numbers below then describe the post-transform - // init (hoisted declarations + chunk calls). With the transform off, they - // describe the original body, which is the useful measurement. - let transform = if entry_outlining_enabled() { - " (PERRY_OUTLINE_ENTRY set — transform already applied; figures are post-transform)" + // The transform runs in the HIR pipeline before codegen. Report clearly + // when these figures describe the compact call stream rather than source + // top-level statements. + let transform = if hir.functions.iter().any(is_entry_chunk) { + " (already outlined; figures describe the chunk-call stream)" } else { "" }; @@ -211,24 +382,33 @@ pub enum OutlineOutcome { Skipped(&'static str), } -/// Largest `FuncId` used anywhere in `hir` — over `functions`, -/// `script_global_functions`, `exported_functions`, and every nested closure -/// `func_id` in a top-level or function body. New chunk ids are minted strictly -/// above this so they can never collide with an existing function or closure. +/// Largest `FuncId` used anywhere in `hir`. New chunk ids are minted strictly +/// above it so generated functions cannot collide with a class member, nested +/// closure, or an id retained only in module metadata. fn max_func_id(hir: &HirModule) -> u32 { let mut max = 0u32; - for f in &hir.functions { - max = max.max(f.id); - } for (_, id) in &hir.script_global_functions { max = max.max(*id); } for (_, id) in &hir.exported_functions { max = max.max(*id); } - // Nested closures carry their own `func_id`; a new chunk id must clear - // those too. `collect_closures_in_stmts` walks stmts + exprs and yields - // every closure id — run it over the init body and every function body. + for id in hir + .async_step_closures + .iter() + .chain(hir.async_generator_funcs.iter()) + { + max = max.max(*id); + } + for id in hir + .closure_display_names + .keys() + .chain(hir.closure_source_text.keys()) + .chain(hir.gen_param_prologue_len.keys()) + { + max = max.max(*id); + } + let collect_max_closure = |stmts: &[perry_hir::Stmt], max: &mut u32| { let mut seen = std::collections::HashSet::new(); let mut out: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); @@ -237,55 +417,155 @@ fn max_func_id(hir: &HirModule) -> u32 { *max = (*max).max(id); } }; + let collect_max_expr = |expr: &perry_hir::Expr, max: &mut u32| { + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); + crate::collectors::collect_closures_in_expr(expr, &mut seen, &mut out); + for (id, _) in out { + *max = (*max).max(id); + } + }; + let collect_function = |function: &perry_hir::Function, max: &mut u32| { + *max = (*max).max(function.id); + collect_max_closure(&function.body, max); + for param in &function.params { + if let Some(default) = ¶m.default { + collect_max_expr(default, max); + } + for decorator in ¶m.decorators { + for arg in &decorator.args { + collect_max_expr(arg, max); + } + } + } + for decorator in &function.decorators { + for arg in &decorator.args { + collect_max_expr(arg, max); + } + } + }; + collect_max_closure(&hir.init, &mut max); for f in &hir.functions { - collect_max_closure(&f.body, &mut max); + collect_function(f, &mut max); + } + for class in &hir.classes { + if let Some(constructor) = &class.constructor { + collect_function(constructor, &mut max); + } + for function in class + .methods + .iter() + .chain(class.static_methods.iter()) + .chain(class.getters.iter().map(|(_, function)| function)) + .chain(class.setters.iter().map(|(_, function)| function)) + .chain(class.computed_members.iter().map(|member| &member.function)) + { + collect_function(function, &mut max); + } + for member in &class.computed_members { + collect_max_expr(&member.key_expr, &mut max); + } + if let Some(expr) = &class.extends_expr { + collect_max_expr(expr, &mut max); + } + for field in class.fields.iter().chain(class.static_fields.iter()) { + if let Some(expr) = &field.key_expr { + collect_max_expr(expr, &mut max); + } + if let Some(expr) = &field.init { + collect_max_expr(expr, &mut max); + } + for decorator in &field.decorators { + for arg in &decorator.args { + collect_max_expr(arg, &mut max); + } + } + } + for decorator in &class.decorators { + for arg in &decorator.args { + collect_max_expr(arg, &mut max); + } + } + } + for global in &hir.globals { + if let Some(expr) = &global.init { + collect_max_expr(expr, &mut max); + } } max } /// A top-level statement the transform can safely relocate into a chunk -/// function without changing semantics or breaking an `hir.init` scan. -/// -/// Deliberately narrow for this first increment: a plain expression statement, -/// or a `let`/`const` binding a SINGLE local to an initializer (split into a -/// hoisted bare declaration plus a `LocalSet` in the chunk). Anything else — -/// destructuring, `var`, top-level control flow, class/enum/import decls — makes -/// the whole body ineligible (the transform bails and the entry compiles -/// unchanged). Extending this set (and the `hir.init` scans that must follow -/// statements into chunks) is the follow-up that reaches real bundles. +/// function without changing which function an abrupt `return` completes. +/// Structured control flow moves as one indivisible statement. A statement +/// containing `return` remains inline; `break`/`continue` stay within the same +/// compound statement and therefore retain their target. fn classify_top_level(stmt: &perry_hir::Stmt) -> Option { use perry_hir::Stmt; match stmt { - Stmt::Expr(_) => Some(TopLevelKind::Expr), - Stmt::Let { - id, init: Some(_), .. - } => Some(TopLevelKind::SimpleLet(*id)), - Stmt::Let { init: None, .. } => Some(TopLevelKind::BareLet), + Stmt::Let { .. } | Stmt::Expr(_) | Stmt::Throw(_) => Some(TopLevelKind::Relocatable), + Stmt::If { .. } + | Stmt::While { .. } + | Stmt::DoWhile { .. } + | Stmt::For { .. } + | Stmt::Labeled { .. } + | Stmt::Try { .. } + | Stmt::Switch { .. } + if !stmt_contains_return(stmt) => + { + Some(TopLevelKind::Relocatable) + } _ => None, } } enum TopLevelKind { - Expr, - SimpleLet(u32), - BareLet, + Relocatable, } -/// Module features whose codegen scans read `hir.init` directly and would -/// therefore miss statements relocated into chunks. Until each such scan is -/// taught to follow chunk calls, a module with any of them is ineligible. -fn has_init_scan_coupling(hir: &HirModule) -> Option<&'static str> { - if !hir.exports.is_empty() || !hir.exported_functions.is_empty() { - return Some("module has exports"); - } - if !hir.script_global_functions.is_empty() { - return Some("script-global function hoisting"); - } - if hir.references_global_this { - return Some("references globalThis"); +fn stmt_contains_return(stmt: &perry_hir::Stmt) -> bool { + use perry_hir::Stmt; + match stmt { + Stmt::Return(_) => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().any(stmt_contains_return) + || else_branch + .as_ref() + .is_some_and(|body| body.iter().any(stmt_contains_return)) + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + body.iter().any(stmt_contains_return) + } + Stmt::For { init, body, .. } => { + init.as_deref().is_some_and(stmt_contains_return) + || body.iter().any(stmt_contains_return) + } + Stmt::Labeled { body, .. } => stmt_contains_return(body), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_contains_return) + || catch + .as_ref() + .is_some_and(|clause| clause.body.iter().any(stmt_contains_return)) + || finally + .as_ref() + .is_some_and(|body| body.iter().any(stmt_contains_return)) + } + Stmt::Switch { cases, .. } => cases + .iter() + .any(|case| case.body.iter().any(stmt_contains_return)), + // A return inside an expression-owned closure completes that closure, + // not module init, so expression walkers are intentionally not used. + _ => false, } - None } /// How many chunk functions the interleaving would emit for `stmts` at @@ -295,31 +575,44 @@ fn has_init_scan_coupling(hir: &HirModule) -> Option<&'static str> { fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { let mut chunks = 0usize; let mut run = 0usize; - let flush = |run: &mut usize, chunks: &mut usize| { + let mut run_safepoints = 0usize; + let flush = |run: &mut usize, run_safepoints: &mut usize, chunks: &mut usize| { if *run > 0 { - *chunks += run.div_ceil(target.max(1)); + *chunks += 1; *run = 0; + *run_safepoints = 0; } }; for stmt in stmts { match classify_top_level(stmt) { - // A bare declaration is hoisted, not executed in a chunk. - Some(TopLevelKind::BareLet) => {} - Some(TopLevelKind::Expr) | Some(TopLevelKind::SimpleLet(_)) => run += 1, - None => flush(&mut run, &mut chunks), + Some(TopLevelKind::Relocatable) => { + run += 1; + run_safepoints = run_safepoints.saturating_add( + crate::collectors::count_safepoint_sites(std::slice::from_ref(stmt)), + ); + if run >= target.max(1) || run_safepoints >= DEFAULT_CHUNK_SAFEPOINTS { + flush(&mut run, &mut run_safepoints, &mut chunks); + } + } + None => flush(&mut run, &mut run_safepoints, &mut chunks), } } - flush(&mut run, &mut chunks); + flush(&mut run, &mut run_safepoints, &mut chunks); chunks } /// Attempt to outline `hir`'s entry body (#8595). Fail-safe: returns /// `Skipped(reason)` and leaves `hir` untouched unless the whole body is /// provably safe to relocate; callers proceed with the ordinary single-function -/// entry lowering in that case. Only runs when `PERRY_OUTLINE_ENTRY` is set. +/// entry lowering in that case. pub fn outline_entry_module(hir: &mut HirModule) -> OutlineOutcome { - if !entry_outlining_enabled() { - return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY not set"); + let mode = outline_mode(); + if mode == OutlineMode::Disabled { + return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY disabled"); + } + let safepoints = crate::collectors::count_safepoint_sites(&hir.init); + if mode == OutlineMode::Auto && !meets_automatic_size_threshold(hir.init.len(), safepoints) { + return OutlineOutcome::Skipped("below automatic outlining threshold"); } outline_entry_module_with_target(hir, target_chunk_stmts()) } @@ -333,36 +626,28 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli if !analysis.is_candidate() { return OutlineOutcome::Skipped("not a candidate (too small)"); } - // Coupling bail: some codegen scans read `hir.init` directly and would - // miss statements moved into chunks. Until each is taught to follow chunk - // calls, a module with one is ineligible. (Empirically, outlining exports / - // globalThis / process.env-literals produces correct output on toy entries, - // so these are candidates for relaxation once validated against the gap - // suite — see #8595.) - if let Some(reason) = has_init_scan_coupling(hir) { - return OutlineOutcome::Skipped(reason); - } - // Pre-scan: decide eligibility before mutating. Outlining is worthwhile // only if the interleaving would emit more than one chunk. - if count_prospective_chunks(&hir.init, target) <= 1 { + let prospective_chunks = count_prospective_chunks(&hir.init, target); + if prospective_chunks <= 1 { return OutlineOutcome::Skipped("would not split into multiple chunks"); } - let mut next_id = max_func_id(hir) + 1; + let max_id = max_func_id(hir); + if prospective_chunks > (u32::MAX - max_id) as usize { + return OutlineOutcome::Skipped("function id space exhausted"); + } + let mut next_id = max_id + 1; let module_name = hir.name.clone(); let original = std::mem::take(&mut hir.init); - // Hoisted bare declarations go to the FRONT of the new init so - // `emit_module_globals` still sees them as top-level `let`s and globalizes - // exactly those referenced across chunks (its existing escape rule). - let mut hoisted: Vec = Vec::new(); // The rewritten body: chunk calls interleaved with any statement that had // to stay inline, in original execution order. let mut new_body: Vec = Vec::new(); let mut chunk_fns: Vec = Vec::new(); // The current run of relocatable statements accumulating into a chunk. let mut run: Vec = Vec::new(); + let mut run_safepoints = 0usize; // Emit the accumulated run as a chunk function and append its call, unless // empty. `flush` is a closure over the mutable state via explicit params to @@ -378,18 +663,21 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli return; } let fn_id = *next_id; - *next_id += 1; + *next_id = (*next_id).saturating_add(1); let ci = chunk_fns.len(); chunk_fns.push(perry_hir::Function { id: fn_id, - name: format!("__perry_entry_chunk_{module_name}_{ci}"), + name: format!("{ENTRY_CHUNK_PREFIX}{module_name}_{ci}"), type_params: Vec::new(), params: Vec::new(), return_type: perry_hir::types::Type::Void, body: std::mem::take(run), is_async: false, is_generator: false, - is_strict: true, + // Entry lowering currently uses `is_strict_fn: false` even for an + // ESM. Match that lowering exactly; HIR already encodes the source + // strictness decisions that affect semantics. + is_strict: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -406,39 +694,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli for stmt in original { match classify_top_level(&stmt) { - Some(TopLevelKind::Expr) | Some(TopLevelKind::BareLet) => { - if let perry_hir::Stmt::Let { .. } = &stmt { - // A bare `let x;` is a pure declaration — hoist it (so it is - // globalized) and add nothing executable to the run. - hoisted.push(stmt); - } else { - run.push(stmt); - } - } - Some(TopLevelKind::SimpleLet(id)) => { - if let perry_hir::Stmt::Let { - id: lid, - name, - ty, - mutable, - init: Some(init), - } = stmt - { - hoisted.push(perry_hir::Stmt::Let { - id: lid, - name, - ty, - mutable, - init: None, - }); - run.push(perry_hir::Stmt::Expr(perry_hir::Expr::LocalSet( - id, - Box::new(init), - ))); - } else { - unreachable!("SimpleLet classification implies Let with init"); - } - } + Some(TopLevelKind::Relocatable) => run.push(stmt), None => { // A statement we cannot safely relocate (control flow, etc.): // end the current chunk run and keep this statement inline, at @@ -451,10 +707,16 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut next_id, &module_name, ); + run_safepoints = 0; new_body.push(stmt); } } - if run.len() >= target { + if let Some(last) = run.last() { + run_safepoints = run_safepoints.saturating_add( + crate::collectors::count_safepoint_sites(std::slice::from_ref(last)), + ); + } + if run.len() >= target.max(1) || run_safepoints >= DEFAULT_CHUNK_SAFEPOINTS { flush( &mut run, &mut chunk_fns, @@ -462,6 +724,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut next_id, &module_name, ); + run_safepoints = 0; } } flush( @@ -474,9 +737,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let chunks = chunk_fns.len(); hir.functions.extend(chunk_fns); - let mut rebuilt = hoisted; - rebuilt.extend(new_body); - hir.init = rebuilt; + hir.init = new_body; OutlineOutcome::Outlined { chunks } } @@ -525,6 +786,47 @@ mod tests { assert_eq!(chunk_ranges(0, 3), Vec::<(usize, usize)>::new()); assert_eq!(chunk_ranges(3, 3), vec![(0, 3)]); assert_eq!(chunk_ranges(7, 3), vec![(0, 3), (3, 6), (6, 7)]); + assert_eq!(chunk_ranges(2, usize::MAX), vec![(0, 2)]); + } + + #[test] + fn safepoint_budget_can_split_before_the_statement_target() { + let allocation_heavy_stmt = || { + Stmt::Expr(Expr::Array( + (0..DEFAULT_CHUNK_SAFEPOINTS) + .map(|_| Expr::Array(vec![])) + .collect(), + )) + }; + let mut m = module_with_init(vec![allocation_heavy_stmt(), allocation_heavy_stmt()]); + assert_eq!( + count_prospective_chunks(&m.init, usize::MAX), + 2, + "each allocation-heavy statement should exhaust a chunk budget" + ); + assert_eq!( + outline_entry_module_with_target(&mut m, usize::MAX), + OutlineOutcome::Outlined { chunks: 2 } + ); + } + + #[test] + fn environment_mode_defaults_to_auto_and_has_explicit_overrides() { + assert_eq!(outline_mode_from_env(None), OutlineMode::Auto); + assert_eq!(outline_mode_from_env(Some("unexpected")), OutlineMode::Auto); + assert_eq!(outline_mode_from_env(Some("1")), OutlineMode::Forced); + assert_eq!(outline_mode_from_env(Some("on")), OutlineMode::Forced); + assert_eq!(outline_mode_from_env(Some("0")), OutlineMode::Disabled); + assert_eq!(outline_mode_from_env(Some("false")), OutlineMode::Disabled); + assert!(!meets_automatic_size_threshold( + DEFAULT_AUTO_MIN_STMTS - 1, + DEFAULT_AUTO_MIN_SAFEPOINTS - 1 + )); + assert!(meets_automatic_size_threshold(DEFAULT_AUTO_MIN_STMTS, 0)); + assert!(meets_automatic_size_threshold( + 1, + DEFAULT_AUTO_MIN_SAFEPOINTS + )); } #[test] @@ -583,8 +885,21 @@ mod tests { assert_eq!(a.gated_out, Some("top-level await")); assert!(!a.is_candidate(), "a gated-out body is never a candidate"); } + + #[test] + fn module_level_tdz_preallocation_gates_the_body_out() { + let m = module_with_init(vec![ + Stmt::PreallocateTdzBoxes(vec![0]), + Stmt::Expr(Expr::LocalGet(0)), + let_stmt(0, "x", Expr::Number(1.0)), + ]); + let a = analyze_entry_outlining_with_target(&m, 1); + assert_eq!(a.gated_out, Some("module-level TDZ preallocation")); + assert!(!a.is_candidate()); + } + #[test] - fn transform_splits_lets_and_emits_ordered_chunk_calls() { + fn transform_preserves_declarations_and_emits_ordered_chunk_calls() { // let x = 1 (chunk 0); read x + let y = 2 (chunk 1); read y (chunk 2) let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), @@ -597,13 +912,9 @@ mod tests { assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); // two chunk functions added assert_eq!(m.functions.len(), before_fns + 2); - // new init: hoisted bare decls for x and y, then two ordered chunk calls - let bare_lets = m - .init - .iter() - .filter(|s| matches!(s, Stmt::Let { init: None, .. })) - .count(); - assert_eq!(bare_lets, 2, "both lets hoisted as bare declarations"); + // The physical init is just the two ordered calls. The logical view + // reconstructs the unchanged declaration statements for codegen scans. + assert_eq!(m.init.len(), 2); let calls: Vec = m .init .iter() @@ -625,11 +936,61 @@ mod tests { m.functions[before_fns + 1].id, "call 1 targets chunk 1" ); - // chunk 0 holds `x = 1` (a LocalSet), no bare let + // Chunk 0 holds the original immutable declaration and initializer; + // it was not degraded into a mutable LocalSet assignment. let chunk0 = &m.functions[before_fns].body; - assert!(chunk0 - .iter() - .any(|s| matches!(s, Stmt::Expr(Expr::LocalSet(0, _))))); + assert!(chunk0.iter().any(|s| matches!( + s, + Stmt::Let { + id: 0, + init: Some(Expr::Number(1.0)), + mutable: false, + .. + } + ))); + let logical = logical_entry_stmts(&m); + assert_eq!(logical.len(), 4); + assert!(matches!(logical[0], Stmt::Let { id: 0, .. })); + assert!(matches!(logical[2], Stmt::Let { id: 1, .. })); + assert!( + outlined_entry_global_let_ids(&m).is_empty(), + "bindings confined to one chunk stay function-local" + ); + } + + #[test] + fn only_boundary_crossing_or_preallocated_bindings_become_globals() { + let mut crossing = module_with_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ]); + assert_eq!( + outline_entry_module_with_target(&mut crossing, 1), + OutlineOutcome::Outlined { chunks: 3 } + ); + assert_eq!( + outlined_entry_global_let_ids(&crossing), + HashSet::from([10]) + ); + + let mut preallocated = module_with_init(vec![ + Stmt::PreallocateBoxes(vec![20]), + Stmt::Try { + body: vec![let_stmt(20, "captured", Expr::Number(2.0))], + catch: None, + finally: None, + }, + Stmt::Expr(Expr::Number(0.0)), + ]); + assert_eq!( + outline_entry_module_with_target(&mut preallocated, 1), + OutlineOutcome::Outlined { chunks: 2 } + ); + assert_eq!( + outlined_entry_global_let_ids(&preallocated), + HashSet::from([20]) + ); } #[test] @@ -655,13 +1016,54 @@ mod tests { was_plain_async: false, was_unrolled: false, }); + m.classes.push(perry_hir::Class { + id: 1, + name: "C".into(), + type_params: vec![], + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![], + constructor: None, + methods: vec![perry_hir::Function { + id: 12_000, + name: "method".into(), + type_params: vec![], + params: vec![], + return_type: Type::Void, + body: vec![], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + }], + getters: vec![], + setters: vec![], + static_accessor_names: vec![], + static_accessor_fn_ids: vec![], + static_fields: vec![], + static_methods: vec![], + computed_members: vec![], + decorators: vec![], + is_exported: false, + aliases: vec![], + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }); let base = m.functions.len(); let outcome = outline_entry_module_with_target(&mut m, 1); assert!(matches!(outcome, OutlineOutcome::Outlined { .. })); for f in &m.functions[base..] { assert!( - f.id > 9000, - "chunk id {} must clear the closure id 9000", + f.id > 12_000, + "chunk id {} must clear closure and class-member ids", f.id ); } @@ -669,16 +1071,12 @@ mod tests { #[test] fn transform_interleaves_chunks_around_a_must_stay_statement() { - // A top-level `if` cannot be relocated; the transform outlines the - // relocatable runs on either side of it and keeps the `if` inline, in - // order. target=1 so each relocatable statement is its own chunk. + // A top-level return cannot move into a helper because it completes + // module init. The transform outlines runs on either side and keeps the + // return inline, in order. target=1 maximizes chunking. let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), // chunk - Stmt::If { - condition: Expr::Bool(true), - then_branch: vec![Stmt::Expr(Expr::LocalGet(0))], - else_branch: None, - }, // must-stay, inline + Stmt::Return(None), // must-stay, inline let_stmt(1, "y", Expr::Number(2.0)), // chunk Stmt::Expr(Expr::LocalGet(1)), // chunk ]); @@ -688,11 +1086,11 @@ mod tests { matches!(outcome, OutlineOutcome::Outlined { .. }), "runs around the if are outlined, not bailed: {outcome:?}" ); - let if_pos = m + let return_pos = m .init .iter() - .position(|s| matches!(s, Stmt::If { .. })) - .expect("the top-level if is kept inline"); + .position(|s| matches!(s, Stmt::Return(_))) + .expect("the top-level return is kept inline"); let call_positions: Vec = m .init .iter() @@ -707,12 +1105,12 @@ mod tests { }) .collect(); assert!( - call_positions.iter().any(|&i| i < if_pos), - "a chunk call precedes the if (the `x` run)" + call_positions.iter().any(|&i| i < return_pos), + "a chunk call precedes the return (the `x` run)" ); assert!( - call_positions.iter().any(|&i| i > if_pos), - "a chunk call follows the if (the `y` run)" + call_positions.iter().any(|&i| i > return_pos), + "a chunk call follows the return (the `y` run)" ); assert!( m.functions.len() > fns_before + 1, @@ -721,13 +1119,26 @@ mod tests { } #[test] - fn transform_bails_when_the_module_has_exports() { + fn structured_control_flow_moves_as_one_indivisible_statement() { + let structured = Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(Expr::Number(1.0))], + else_branch: None, + }; + let mut m = module_with_init(vec![structured, Stmt::Expr(Expr::Number(2.0))]); + let outcome = outline_entry_module_with_target(&mut m, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); + assert!(matches!(m.functions[0].body.as_slice(), [Stmt::If { .. }])); + } + + #[test] + fn exported_modules_are_eligible() { let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), Stmt::Expr(Expr::LocalGet(0)), ]); m.exported_functions.push(("g".into(), 42)); let outcome = outline_entry_module_with_target(&mut m, 1); - assert_eq!(outcome, OutlineOutcome::Skipped("module has exports")); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); } } diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index b05d24cae5..7c14e43216 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -580,6 +580,11 @@ pub(super) fn compile_function( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let lf = llmod.define_function(&llvm_name, DOUBLE, params); + let entry_outline_chunk = super::entry_outline::is_entry_chunk(f); + // #8595: these functions exist specifically to bound backend work. Letting + // the ordinary or pre-statepoint inliner fold them back into module init + // would recreate the single giant function before RS4GC/ISel/regalloc. + lf.no_inline = entry_outline_chunk; if typed_public_trampoline.is_some() || guarded_public_plan.is_some() || spec_entry.is_some() @@ -620,7 +625,8 @@ pub(super) fn compile_function( // rewritten wrapper into its caller breaks GC-root coverage of the // step closure's iter capture, hanging async chains (issue #447). let specialized_entry = spec_entry.is_some(); - if !specialized_entry + if !entry_outline_chunk + && !specialized_entry && f.body.len() <= 8 && !f.is_async && !f.is_generator @@ -647,7 +653,8 @@ pub(super) fn compile_function( // as `hot_loop_callee` (before the entry block exists and before any // expression is lowered), for the same reason. lf.alloc_hot = cross_module.alloc_hot_functions.contains(&f.id); - if !specialized_entry + if !entry_outline_chunk + && !specialized_entry && !lf.force_inline && inline_hot_small_enabled() && (INLINE_HOT_SMALL_MIN..=inline_hot_small_size_cap()).contains(&f.body.len()) diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index b825dbf7d5..b13b217820 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -7,9 +7,6 @@ use std::collections::HashMap; -use anyhow::Result; -use perry_hir::Module as HirModule; - use crate::module::LlModule; use crate::types::{DOUBLE, I32, I64, PTR}; diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d18666bbd4..acc8e7f11e 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1409,6 +1409,12 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } // macOS / darwin default }; progress.checkpoint("symbol tables and initial declarations"); + // #8595: after entry outlining, declaration-bearing statements live in + // compiler-owned chunk functions. Analyses that model the module's source + // environment use the reconstructed stream, not the compact call-only + // `hir.init`, so immutable initializer facts and TDZ/prealloc metadata are + // unchanged by the structural transform. + let logical_entry_stmts = entry_outline::logical_entry_stmts(hir); // Pre-scan hir.init for compile-time constant variables. These are // `declare const __platform__: number` / `declare const __plugins__: number` @@ -1416,7 +1422,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // uses these to constant-fold platform checks in `lower_if`, eliminating // dead branches that reference extern FFI functions absent on the target. let mut compile_time_constants: HashMap = HashMap::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, name, @@ -1445,14 +1451,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // ReferenceError on pre-declaration reads instead of folding to a value. { let mut prealloc_ids: std::collections::HashSet = std::collections::HashSet::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::PreallocateBoxes(ids) | perry_hir::Stmt::PreallocateTdzBoxes(ids) = s { prealloc_ids.extend(ids.iter().copied()); } } - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, mutable: false, @@ -2227,7 +2233,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // in the module (LocalSet/Update/IndexSet/mutating methods). let mut map: std::collections::HashMap = std::collections::HashMap::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, init: Some(init), diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 368a054156..905945538e 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -341,8 +341,12 @@ pub(crate) fn emit_module_globals( } } } + let logical_entry = super::entry_outline::logical_entry_stmts(hir); + let outlined_entry_globals = super::entry_outline::outlined_entry_global_let_ids(hir); let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); - collect_init_lets(&hir.init, &mut init_lets); + for stmt in logical_entry { + collect_init_lets(std::slice::from_ref(stmt), &mut init_lets); + } // `Expr::New { class_name }` does not retain whether an unqualified name // came from the intrinsic or a same-named runtime binding. Mirror HIR's // `shadows_unqualified_global` categories here, plus the module-level HIR @@ -374,7 +378,10 @@ pub(crate) fn emit_module_globals( { module_global_proven_types.insert(*id, proven); } - if referenced_from_fn.contains(id) || exported_var_names.contains(name) { + if outlined_entry_globals.contains(id) + || referenced_from_fn.contains(id) + || exported_var_names.contains(name) + { // A `var` redeclared at module scope (`var x = …; … var x = …;`) // lowers to multiple `Stmt::Let` sharing the SAME id. The backing // global (and any exported getter) is keyed by that id, so emit it diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index 4bde689e5a..f32e4c62dc 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -306,16 +306,18 @@ pub(super) fn init_static_fields_late( // reassignment made between the class decl and end of module // init. Mirrors the static-block dedup below. The inline // lowering also registers the field in CLASS_DYNAMIC_PROPS. - let inline_initialized = hir.init.iter().any(|s| { - matches!( - s, - perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { - class_name, - field_name, - .. - }) if *class_name == c.name && *field_name == sf.name - ) - }); + let inline_initialized = super::entry_outline::logical_entry_stmts(hir) + .into_iter() + .any(|s| { + matches!( + s, + perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { + class_name, + field_name, + .. + }) if *class_name == c.name && *field_name == sf.name + ) + }); if inline_initialized { continue; } diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 0ae01715ea..53a0f632e8 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -59,7 +59,7 @@ pub use clamp_detect::{detect_clamp3, detect_clamp_u8, returns_i32_identity_arg, // transitively expose through `pub(crate) use crate::collectors::*`. pub(crate) use byte_read_key::{collect_numeric_typed_locals, uint8array_get_reads_a_byte}; pub(crate) use class_accessors::{is_class_getter, is_class_setter}; -pub(crate) use closures::collect_closures_in_stmts; +pub(crate) use closures::{collect_closures_in_expr, collect_closures_in_stmts}; pub(crate) use escape_arrays::{const_index, MAX_SCALAR_OBJECT_FIELDS}; pub(crate) use escape_check::{check_escapes_in_stmts, find_new_candidates}; pub(crate) use escape_news::MAX_SCALAR_ARRAY_LEN; diff --git a/crates/perry-codegen/src/dialect/eh.rs b/crates/perry-codegen/src/dialect/eh.rs index 04f28549fa..11b04067fe 100644 --- a/crates/perry-codegen/src/dialect/eh.rs +++ b/crates/perry-codegen/src/dialect/eh.rs @@ -52,6 +52,7 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { let callee = &after[..paren]; let close = rmatch_paren(after, paren)?; let args_str = &after[paren + 1..close]; + let trailing_attr = after[close + 1..].trim(); // `build_indirect_invoke` takes basic values (not metadata enums // like the call path), so collect both shapes once. @@ -85,6 +86,19 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { if preserve_none { site.set_call_convention(super::LLVM_CC_PRESERVE_NONE); } + match trailing_attr { + "" => {} + // #8596: whole-module GC-effect closure can prove a direct + // generated callee transitively non-collecting even inside a try. + // The textual path places the call-site attribute before + // `to label`; reproduce it in the C-API path or native units gain + // statepoints the text units do not have. + "\"gc-leaf-function\"" => site.add_attribute( + inkwell::attributes::AttributeLoc::Function, + self.ctx.create_string_attribute("gc-leaf-function", ""), + ), + other => bail!("unknown invoke callsite attribute `{other}`"), + } // An invoke terminates its block; the emitted text continues in // the inline continuation label, which arrives as the next line. match site.try_as_basic_value() { diff --git a/crates/perry-codegen/src/dialect/tests.rs b/crates/perry-codegen/src/dialect/tests.rs index 36121796a5..9905c46288 100644 --- a/crates/perry-codegen/src/dialect/tests.rs +++ b/crates/perry-codegen/src/dialect/tests.rs @@ -381,3 +381,39 @@ fn preserve_none_constructs_on_define_call_and_invoke() { "invoke site lost its calling convention:\n{printed}" ); } + +/// #8596: a transitive-leaf direct call inside `try` is an invoke, and LLVM's +/// call-site attribute sits between the argument list and `to label`. The +/// split-module native reader must carry it onto the CallBase or RS4GC silently +/// restores a statepoint that the text path removed. +#[test] +fn gc_leaf_attribute_constructs_on_invoke() { + let ctx = Context::create(); + let skeleton = "declare void @pure()\n\ + declare i32 @perry_eh_personality(i32, i32, i64, ptr, ptr)\n"; + let module = crate::inprocess::parse_ir_text(&ctx, skeleton, "leaf_invoke_skel") + .expect("skeleton parses"); + let function = "define void @trycaller() personality ptr @perry_eh_personality {\n\ + entry:\n\ + \x20 invoke void @pure() \"gc-leaf-function\" to label %ok unwind label %pad\n\ + ok:\n\ + \x20 ret void\n\ + pad:\n\ + \x20 %lp = landingpad { ptr, i32 } catch ptr null\n\ + \x20 ret void\n\ + }\n"; + predeclare_function_from_text(&ctx, &module, function).expect("predeclare"); + add_function_from_text(&ctx, &module, function).unwrap_or_else(|e| panic!("{e:#}")); + module + .verify() + .unwrap_or_else(|e| panic!("verifier rejected native module:\n{}", e.to_string())); + let printed = module.print_to_string().to_string(); + let invoke = printed + .lines() + .find(|line| line.contains("invoke void @pure")) + .unwrap_or_else(|| panic!("no invoke in constructed module:\n{printed}")); + assert!( + invoke.contains("#0") && printed.contains("attributes #0 = { \"gc-leaf-function\" }"), + "invoke lost its gc-leaf-function call-site attribute:\n{printed}" + ); +} diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 39d2f54186..635b3625c9 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -918,6 +918,14 @@ impl LlFunction { } pub fn to_ir(&self) -> String { + self.to_ir_with_gc_leaf_callees(&HashSet::new()) + } + + /// Render with the module's transitive Perry-GC leaf closure available at + /// direct call sites. Standalone function tests use [`Self::to_ir`] and an + /// empty set; module and codegen-unit renderers compute the whole-module + /// fixed point before serializing any function. + pub(crate) fn to_ir_with_gc_leaf_callees(&self, gc_leaf_callees: &HashSet) -> String { let mut ir = self.define_header(false); ir.push('\n'); self.for_each_final_line::(&mut |line| { @@ -944,6 +952,18 @@ impl LlFunction { ir }; + // #8596: LLVM needs a statepoint at a caller edge exactly when the + // transitive callee can reach collection. The whole-module analysis + // proves direct generated callees that cannot; stamp those edges after + // root lowering (which separately handles audited runtime helpers). + // Unknown, indirect, cross-module and collecting callees remain + // unmarked and therefore remain statepoints. + let ir = if self.stack_map_requested && !gc_leaf_callees.is_empty() { + crate::gc_call_effects::annotate_transitive_leaf_calls(&ir, gc_leaf_callees) + } else { + ir + }; + // RS4GC uses the unwind destination's landing pad **as the token** for // the relocates it inserts on the exceptional edge, so // `statepoint-example` requires that pad to be `landingpad token`. diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 3c4a89c81f..ac65c3f397 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -9,6 +9,11 @@ //! auditing the complete runtime call graph for `gc_check_trigger`, //! `js_gc_collect`, `js_gc_loop_safepoint`, or another route into collection. +use std::collections::{HashMap, HashSet}; + +use crate::function::{FinalItem, LlFunction}; +use crate::inst::LlInst; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum GcCallEffect { CannotCollect, @@ -55,6 +60,21 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { | "js_gc_temp_root_get" | "js_gc_temp_root_set" | "js_gc_temp_root_truncate" + // Heap-shadow-frame bookkeeping. These helpers touch only the + // thread-local shadow buffer; growth is a raw Rust Vec allocation, + // and slot writes may run the incremental-mark root barrier, neither + // of which can enter Perry's collector. Native-root functions consume + // bind/set calls before RS4GC, while #8583-spilled functions retain + // them. Classifying both forms lets the module call-graph closure prove + // an otherwise-leaf spilled callee without pretending its frame + // maintenance is a safepoint. All six are in the root-dominance + // checker's NONCOLLECTING authority. + | "js_shadow_frame_enter" + | "js_shadow_frame_push" + | "js_shadow_frame_pop" + | "js_shadow_state_addr" + | "js_shadow_slot_bind" + | "js_shadow_slot_set" // `gc/barrier.rs`: remembered-set / incremental-marking maintenance. | "js_write_barrier" | "js_write_barrier_slot" @@ -236,6 +256,250 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { } } +/// Whether a direct external call is a Perry-GC leaf in this compile. +/// +/// `AllocNoReentry` is deliberately conditional: without the strict +/// safepoint-only contract those helpers may collect synchronously at their +/// allocation site, so every caller frame on the stack still needs a +/// statepoint at the edge that reached them. +fn external_callee_cannot_collect(name: &str) -> bool { + name.starts_with("llvm.") + || match classify_direct_callee(name) { + GcCallEffect::CannotCollect => true, + GcCallEffect::AllocNoReentry => { + crate::codegen::helpers::gc_safepoint_only_contract_enabled() + } + GcCallEffect::Unknown => false, + } +} + +/// The direct callee token and its argument-list opening parenthesis. +/// +/// Perry's closed IR dialect emits unquoted `[-A-Za-z0-9_.$]` symbols. Search +/// for the first `%name(`/`@name(` token after the call opcode rather than the +/// first `(`: return types such as `ptr addrspace(1)` contain parentheses too. +/// Choosing the first sigil also fails closed for an indirect call whose +/// arguments later contain a direct-function constant. +fn direct_callee_span(line: &str) -> Option<(&str, usize)> { + let trimmed = line.trim_start(); + let leading = line.len() - trimmed.len(); + // Prefer invoke before searching for `call`: a later argument or inline + // constant may contain those bytes, but it is never the opcode of an + // invoke line. + let opcode = if let Some(rest) = trimmed.strip_prefix("invoke ") { + trimmed.len() - rest.len() + } else if let Some(pos) = trimmed.find(" = invoke ") { + pos + " = invoke ".len() + } else if let Some(pos) = trimmed.find("call ") { + pos + "call ".len() + } else { + return None; + }; + let tail = &trimmed[opcode..]; + let bytes = tail.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if matches!(bytes[i], b'@' | b'%') { + let sigil = bytes[i]; + let start = i + 1; + let mut end = start; + while end < bytes.len() + && (bytes[end].is_ascii_alphanumeric() + || matches!(bytes[end], b'_' | b'.' | b'$' | b'-')) + { + end += 1; + } + if end > start && bytes.get(end) == Some(&b'(') { + if sigil == b'%' { + return None; + } + return Some((&tail[start..end], leading + opcode + end)); + } + i = end.max(i + 1); + } else { + i += 1; + } + } + None +} + +fn line_is_call_like(line: &str) -> bool { + let t = line.trim_start(); + t.starts_with("call ") + || t.starts_with("tail call ") + || t.starts_with("musttail call ") + || t.starts_with("notail call ") + || t.contains(" = call ") + || t.contains(" = tail call ") + || t.contains(" = musttail call ") + || t.contains(" = notail call ") + || t.starts_with("invoke ") + || t.contains(" = invoke ") +} + +fn matching_call_paren(line: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quoted = false; + let mut escaped = false; + for (offset, ch) in line[open..].char_indices() { + if quoted { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + quoted = false; + } + continue; + } + match ch { + '"' => quoted = true, + '(' => depth += 1, + ')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + } + None +} + +/// Add LLVM's call-site leaf marker to direct calls of `known_leaf_callees`. +/// +/// This runs after Perry's native-root lowering, which already annotates the +/// audited runtime-helper table. It handles both `call` and `invoke`; for an +/// invoke the attribute belongs between `@callee(args)` and `to label`. +pub(crate) fn annotate_transitive_leaf_calls( + ir: &str, + known_leaf_callees: &HashSet, +) -> String { + if known_leaf_callees.is_empty() { + return ir.to_string(); + } + let mut out = String::with_capacity(ir.len()); + for line in ir.lines() { + let rewritten = (line_is_call_like(line) && !line.contains(" asm ")) + .then(|| direct_callee_span(line)) + .flatten() + .and_then(|(callee, open)| { + if !known_leaf_callees.contains(callee) || line.contains("\"gc-leaf-function\"") { + return None; + } + let close = matching_call_paren(line, open)?; + let mut marked = String::with_capacity(line.len() + 19); + marked.push_str(&line[..=close]); + marked.push_str(" \"gc-leaf-function\""); + marked.push_str(&line[close + 1..]); + Some(marked) + }); + out.push_str(rewritten.as_deref().unwrap_or(line)); + out.push('\n'); + } + out +} + +#[derive(Default)] +struct FunctionEffects { + internal_callees: HashSet, + has_collecting_edge: bool, +} + +fn note_direct_callee(effects: &mut FunctionEffects, callee: &str, defined: &HashSet<&str>) { + if defined.contains(callee) { + effects.internal_callees.insert(callee.to_string()); + } else if !external_callee_cannot_collect(callee) { + effects.has_collecting_edge = true; + } +} + +fn note_text_effects(line: &str, effects: &mut FunctionEffects, defined: &HashSet<&str>) { + if !line_is_call_like(line) || line.contains("\"gc-leaf-function\"") || line.contains(" asm ") { + return; + } + match direct_callee_span(line) { + Some((callee, _)) => note_direct_callee(effects, callee, defined), + None => effects.has_collecting_edge = true, + } +} + +fn effects_of(function: &LlFunction, defined: &HashSet<&str>) -> FunctionEffects { + let mut effects = FunctionEffects::default(); + function + .for_each_final_item::(&mut |item| { + match item { + FinalItem::Inst(LlInst::Call { callee, .. }) => { + note_direct_callee(&mut effects, callee, defined) + } + FinalItem::Inst(LlInst::CallIndirect { .. }) => effects.has_collecting_edge = true, + FinalItem::Inst(LlInst::AsmBarrier) => {} + FinalItem::Inst(LlInst::Raw(line)) => { + note_text_effects(line, &mut effects, defined) + } + FinalItem::Text(line) => note_text_effects(line, &mut effects, defined), + FinalItem::Label(_) | FinalItem::Blank | FinalItem::Inst(_) => {} + } + Ok(()) + }) + .unwrap_or_else(|e| match e {}); + effects +} + +/// Compute the largest sound set of module-defined functions that cannot +/// reach Perry's collector. +/// +/// Start with every definition as a candidate and remove functions with an +/// unknown/indirect/collecting external edge, then propagate removal backwards +/// through direct calls. This greatest-fixed-point formulation admits pure +/// recursive SCCs while rejecting an SCC as soon as any member can allocate, +/// poll, throw through an allocating helper, call indirectly, or leave the +/// module through an unaudited symbol. +/// +/// A caller suspended below a collecting callee still needs a statepoint even +/// when collection begins only at an allocation or loop poll: the moving +/// collector must find and rewrite that caller's frame. Consequently this set +/// is the safe part of polling-style density reduction; calls outside it must +/// remain statepoints. +pub(crate) fn transitive_leaf_functions(functions: &[&LlFunction]) -> HashSet { + let defined: HashSet<&str> = functions.iter().map(|f| f.name.as_str()).collect(); + let effects: HashMap<&str, FunctionEffects> = functions + .iter() + .map(|f| (f.name.as_str(), effects_of(f, &defined))) + .collect(); + + let mut collecting: HashSet<&str> = effects + .iter() + .filter_map(|(&name, effect)| effect.has_collecting_edge.then_some(name)) + .collect(); + // Reverse edges make propagation O(functions + calls). Re-scanning every + // function once per newly-unsafe layer is quadratic on a long generated + // call chain -- exactly the scale this optimization is meant to help. + let mut callers: HashMap<&str, Vec<&str>> = HashMap::new(); + for (&caller, effect) in &effects { + for callee in &effect.internal_callees { + callers.entry(callee.as_str()).or_default().push(caller); + } + } + let mut work: Vec<&str> = collecting.iter().copied().collect(); + while let Some(callee) = work.pop() { + if let Some(direct_callers) = callers.get(callee) { + for &caller in direct_callers { + if collecting.insert(caller) { + work.push(caller); + } + } + } + } + + defined + .into_iter() + .filter(|name| !collecting.contains(name)) + .map(str::to_string) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -436,6 +700,12 @@ mod tests { fn audited_runtime_bookkeeping_cannot_collect() { for name in [ "js_gc_temp_root_push", + "js_shadow_frame_enter", + "js_shadow_frame_push", + "js_shadow_frame_pop", + "js_shadow_state_addr", + "js_shadow_slot_bind", + "js_shadow_slot_set", "js_write_barrier_root_nanbox", "js_gc_note_slot_layout", "js_typed_feedback_record_guard_pass", @@ -600,4 +870,159 @@ mod tests { ); } } + + fn void_function(name: &str, calls: &[&str]) -> LlFunction { + let mut f = LlFunction::new(name, crate::types::VOID, vec![]); + let entry = f.create_block("entry"); + for callee in calls { + entry.call_void(callee, &[]); + } + entry.ret_void(); + f + } + + /// #8596: the greatest fixed point admits a pure recursive component, but + /// one collecting exit poisons every direct caller that can reach it. + #[test] + fn transitive_leaf_closure_handles_recursion_and_collecting_exits() { + let leaf = void_function("leaf", &["js_nanbox_pointer"]); + let wrapper = void_function("wrapper", &["leaf"]); + let recursive_a = void_function("recursive_a", &["recursive_b"]); + let recursive_b = void_function("recursive_b", &["recursive_a"]); + let allocating = void_function("allocating", &["js_array_alloc"]); + let reaches_allocating = void_function("reaches_allocating", &["allocating"]); + let functions = [ + &leaf, + &wrapper, + &recursive_a, + &recursive_b, + &allocating, + &reaches_allocating, + ]; + + let safe = transitive_leaf_functions(&functions); + for name in ["leaf", "wrapper", "recursive_a", "recursive_b"] { + assert!(safe.contains(name), "{name} should be transitively leaf"); + } + for name in ["allocating", "reaches_allocating"] { + assert!( + !safe.contains(name), + "{name} reaches Perry allocation and must remain a safepoint callee" + ); + } + } + + #[test] + fn indirect_and_unknown_external_edges_fail_closed() { + let unknown = void_function("unknown", &["cross_module_function"]); + let mut indirect = LlFunction::new("indirect", crate::types::VOID, vec![]); + let entry = indirect.create_block("entry"); + entry.call_indirect(crate::types::I64, "%callback", &[]); + entry.ret_void(); + let functions = [&unknown, &indirect]; + + let safe = transitive_leaf_functions(&functions); + assert!( + safe.is_empty(), + "unknown and indirect calls must fail closed" + ); + } + + #[test] + fn annotates_call_and_invoke_at_the_llvm_attribute_position() { + let known = HashSet::from(["pure".to_string()]); + let ir = " %a = call ptr addrspace(1) @pure(ptr addrspace(1) %p)\n\ + %b = invoke preserve_nonecc double @pure(double %x) to label %ok unwind label %pad\n\ + %c = call double @collecting()\n\ + %d = call double %callback(ptr @pure)\n\ + ; call void @pure() is documentation, not an instruction\n"; + let marked = annotate_transitive_leaf_calls(ir, &known); + assert!(marked.contains( + "%a = call ptr addrspace(1) @pure(ptr addrspace(1) %p) \"gc-leaf-function\"" + )); + assert!(marked.contains( + "%b = invoke preserve_nonecc double @pure(double %x) \"gc-leaf-function\" to label %ok unwind label %pad" + )); + assert!(marked.contains("%c = call double @collecting()\n")); + assert!(marked.contains("%d = call double %callback(ptr @pure)\n")); + assert_eq!( + marked.matches("\"gc-leaf-function\"").count(), + 2, + "only the two proven direct calls may be annotated:\n{marked}" + ); + } + + /// End-to-end emission witness: the analysis is module-wide and the leaf + /// set reaches a rooted caller's final IR. The allocating sibling is the + /// discriminating control and must remain unmarked for RS4GC to rewrite. + #[test] + fn module_marks_only_transitively_noncollecting_generated_calls() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let mut module = crate::module::LlModule::new(crate::codegen::default_target_triple()); + module.declare_function("js_array_alloc", crate::types::I64, &[crate::types::I32]); + module.declare_function( + "js_shadow_slot_bind", + crate::types::VOID, + &[crate::types::I32, crate::types::PTR], + ); + + let pure = module.define_function("pure_generated", crate::types::VOID, vec![]); + pure.create_block("entry").ret_void(); + + let allocating = module.define_function("allocating_generated", crate::types::VOID, vec![]); + let entry = allocating.create_block("entry"); + entry.call( + crate::types::I64, + "js_array_alloc", + &[(crate::types::I32, "0")], + ); + entry.ret_void(); + + let caller = module.define_function("rooted_caller", crate::types::VOID, vec![]); + caller.enable_shadow_frame(0); + let slot = caller.reserve_shadow_slot().expect("reserve native root"); + let root = caller.alloca_entry(crate::types::I64); + caller.entry_allocas_push_store(crate::types::I64, "0", &root); + caller.entry_setup_call_void( + "js_shadow_slot_bind", + &[ + (crate::types::I32, &slot.to_string()), + (crate::types::PTR, &root), + ], + ); + let entry = caller.create_block("entry"); + entry.call_void("pure_generated", &[]); + entry.call_void("allocating_generated", &[]); + entry.ret_void(); + + let ir = module.to_ir(); + assert!(ir.contains("call void @pure_generated() \"gc-leaf-function\"")); + assert!( + ir.contains("call void @allocating_generated()") + && !ir.contains("call void @allocating_generated() \"gc-leaf-function\""), + "allocating generated callee must remain a statepoint edge:\n{ir}" + ); + + #[cfg(feature = "llvm-inprocess")] + { + let target = crate::codegen::default_target_triple(); + let rewritten = crate::inprocess::statepoint_rewritten_ir( + &ir, + &target, + "transitive_leaf_generated_calls", + ) + .expect("module-wide leaf witness must survive RS4GC"); + assert!( + rewritten.contains("call void @pure_generated()"), + "proven leaf call was unexpectedly rewritten:\n{rewritten}" + ); + assert!( + rewritten.lines().any(|line| { + line.contains("@llvm.experimental.gc.statepoint") + && line.contains("@allocating_generated") + }), + "collecting generated call did not become a statepoint:\n{rewritten}" + ); + } + } } diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 4ebba36f4e..81ef383ff4 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -123,9 +123,9 @@ use ui_styling::apply_inline_style; // table-lookup family and `lower_perry_ui_table_call` unchanged. pub(super) use ui_tables::{ lower_perry_ui_table_call, perry_audio_table_lookup, perry_background_table_lookup, - perry_i18n_table_lookup, perry_media_table_lookup, perry_plugin_instance_method_lookup, - perry_plugin_table_lookup, perry_system_table_lookup, perry_ui_instance_method_lookup, - perry_ui_table_lookup, perry_updater_table_lookup, + perry_i18n_table_lookup, perry_ios_table_lookup, perry_media_table_lookup, + perry_plugin_instance_method_lookup, perry_plugin_table_lookup, perry_system_table_lookup, + perry_ui_instance_method_lookup, perry_ui_table_lookup, perry_updater_table_lookup, }; // Same for `native_module_dispatch.rs` — `native.rs` consumes both // `native_module_lookup` and `lower_native_module_dispatch` via diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index cdfdc4812b..e07ecbb1e8 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -41,10 +41,10 @@ pub(super) use super::{ find_outer_writes_stmt, find_thread_hazard_in_body, get_raw_string_ptr, hazardous_module_global_ids, lower_fetch_native_method, lower_native_module_dispatch, lower_notification_schedule, lower_perry_ui_table_call, native_module_lookup, - perry_audio_table_lookup, perry_i18n_table_lookup, perry_media_table_lookup, - perry_plugin_instance_method_lookup, perry_plugin_table_lookup, perry_system_table_lookup, - perry_ui_instance_method_lookup, perry_ui_table_lookup, perry_updater_table_lookup, - ThreadClosureHazard, + perry_audio_table_lookup, perry_i18n_table_lookup, perry_ios_table_lookup, + perry_media_table_lookup, perry_plugin_instance_method_lookup, perry_plugin_table_lookup, + perry_system_table_lookup, perry_ui_instance_method_lookup, perry_ui_table_lookup, + perry_updater_table_lookup, ThreadClosureHazard, }; mod box_style; diff --git a/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs b/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs index 9b48f0de28..67dac4fa4e 100644 --- a/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_ui_widgets_branch.rs @@ -346,6 +346,26 @@ ); } + // iOS-only adaptive scene geometry + Foundation Models (#5536). The + // module is deliberately platform-specific so other UI backends never + // receive unresolved UIKit/Swift symbols. + if module == "perry/ios" && object.is_none() { + if !ctx.target_triple.contains("apple-ios") { + bail!( + "perry/ios is only available for --target ios or ios-simulator (current target: {})", + ctx.target_triple + ); + } + if let Some(sig) = perry_ios_table_lookup(method) { + return lower_perry_ui_table_call(ctx, sig, args); + } + bail!( + "perry/ios: '{}' is not a known function (args: {}). Check types/perry/ios/index.d.ts for the supported API surface.", + method, + args.len() + ); + } + // perry/i18n format wrappers: Currency, Percent, FormatNumber, ShortDate, // LongDate, FormatTime, Raw. Without this, the call falls through to the // receiver-less early-out and returns NaN-boxed `undefined` (issue #188). diff --git a/crates/perry-codegen/src/lower_call/ui_tables.rs b/crates/perry-codegen/src/lower_call/ui_tables.rs index 31e824413a..7d99348c1f 100644 --- a/crates/perry-codegen/src/lower_call/ui_tables.rs +++ b/crates/perry-codegen/src/lower_call/ui_tables.rs @@ -16,8 +16,8 @@ use crate::types::{DOUBLE, I64}; use perry_dispatch::{ ArgKind as UiArgKind, MethodRow as UiSig, ReturnKind as UiReturnKind, PERRY_AUDIO_TABLE, - PERRY_BACKGROUND_TABLE, PERRY_I18N_TABLE, PERRY_MEDIA_TABLE, PERRY_SYSTEM_TABLE, - PERRY_UI_INSTANCE_TABLE, PERRY_UI_TABLE, PERRY_UPDATER_TABLE, + PERRY_BACKGROUND_TABLE, PERRY_I18N_TABLE, PERRY_IOS_TABLE, PERRY_MEDIA_TABLE, + PERRY_SYSTEM_TABLE, PERRY_UI_INSTANCE_TABLE, PERRY_UI_TABLE, PERRY_UPDATER_TABLE, }; use super::apply_inline_style; @@ -54,6 +54,14 @@ pub fn perry_media_table_lookup(method: &str) -> Option<&'static UiSig> { PERRY_MEDIA_TABLE.iter().find(|s| s.method == method) } +// ============================================================================= +// perry/ios dispatch table (issue #5536) +// ============================================================================= + +pub fn perry_ios_table_lookup(method: &str) -> Option<&'static UiSig> { + PERRY_IOS_TABLE.iter().find(|s| s.method == method) +} + // ============================================================================= // perry/i18n format-wrapper dispatch table // ============================================================================= diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index c004993874..dc81409128 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -13,6 +13,7 @@ use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::rc::Rc; +use std::sync::Arc; use crate::block::FpFlags; use crate::function::LlFunction; @@ -299,7 +300,14 @@ pub(crate) fn declare_line_for(f: &LlFunction) -> String { /// `private` definition so cross-unit calls can bind to it. Names are /// module-prefixed and unique, so promotion never collides. pub(crate) fn render_fn_external(f: &LlFunction) -> String { - let ir = f.to_ir(); + render_fn_external_with_gc_leaf_callees(f, &HashSet::new()) +} + +pub(crate) fn render_fn_external_with_gc_leaf_callees( + f: &LlFunction, + gc_leaf_callees: &HashSet, +) -> String { + let ir = f.to_ir_with_gc_leaf_callees(gc_leaf_callees); if f.linkage == "internal" || f.linkage == "private" { return ir.replacen(&format!("define {} ", f.linkage), "define ", 1); } @@ -789,6 +797,11 @@ impl LlModule { ir.push('\n'); let funcs = self.deduped_function_refs(); + let gc_leaf_callees = if crate::codegen::helpers::native_stack_roots_enabled() { + crate::gc_call_effects::transitive_leaf_functions(&funcs) + } else { + HashSet::new() + }; // Skip any `declare` whose name is also `define`d in this module — // LLVM rejects declare+define for the same symbol. @@ -806,7 +819,7 @@ impl LlModule { ir.push('\n'); for func in &funcs { - ir.push_str(&func.to_ir()); + ir.push_str(&func.to_ir_with_gc_leaf_callees(&gc_leaf_callees)); ir.push('\n'); } @@ -917,11 +930,17 @@ impl LlModule { /// into one object, keeping `compile_module`'s single-object API. pub(crate) fn codegen_unit_parts(&self, n: usize) -> Vec> { let funcs = self.deduped_function_refs(); + let gc_leaf_callees = Arc::new(if crate::codegen::helpers::native_stack_roots_enabled() { + crate::gc_call_effects::transitive_leaf_functions(&funcs) + } else { + HashSet::new() + }); if n <= 1 || funcs.len() <= 1 { return vec![CodegenUnitPart { pre: String::new(), post: String::new(), funcs, + gc_leaf_callees, }]; } let n = n.min(funcs.len()); @@ -1155,6 +1174,7 @@ impl LlModule { pre, post: unit_posts[bi].clone(), funcs: bucket, + gc_leaf_callees: Arc::clone(&gc_leaf_callees), }); } parts @@ -1167,7 +1187,7 @@ impl LlModule { /// function graph as soon as its immutable worker payload exists instead /// of retaining the whole `LlModule` until every LLVM unit has finished. pub(crate) fn into_codegen_unit_parts(mut self, n: usize) -> Vec { - let layouts: Vec<(String, String, Vec)> = self + let layouts: Vec<(String, String, Vec, Arc>)> = self .codegen_unit_parts(n) .into_iter() .map(|part| { @@ -1175,6 +1195,7 @@ impl LlModule { part.pre, part.post, part.funcs.iter().map(|func| func.name.clone()).collect(), + part.gc_leaf_callees, ) }) .collect(); @@ -1187,7 +1208,7 @@ impl LlModule { layouts .into_iter() - .map(|(pre, post, names)| OwnedCodegenUnitPart { + .map(|(pre, post, names, gc_leaf_callees)| OwnedCodegenUnitPart { pre, post, funcs: names @@ -1198,6 +1219,7 @@ impl LlModule { .expect("borrowed codegen partition named an owned function") }) .collect(), + gc_leaf_callees, }) .collect() } @@ -1215,7 +1237,10 @@ impl LlModule { .map(|part| { let mut ir = part.pre; for func in &part.funcs { - ir.push_str(&render_fn_external(func)); + ir.push_str(&render_fn_external_with_gc_leaf_callees( + func, + &part.gc_leaf_callees, + )); ir.push('\n'); } ir.push_str(&part.post); @@ -1233,12 +1258,14 @@ pub(crate) struct CodegenUnitPart<'m> { pub pre: String, pub post: String, pub funcs: Vec<&'m LlFunction>, + pub gc_leaf_callees: Arc>, } pub(crate) struct OwnedCodegenUnitPart { pub pre: String, pub post: String, pub funcs: Vec, + pub gc_leaf_callees: Arc>, } #[cfg(test)] diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 9258884027..44934b8a11 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -89,7 +89,9 @@ fn build_native_module<'ctx>(context: &'ctx Context, llmod: &LlModule) -> Result skeleton.push_str(&format!("declare {} @{}({})\n", f.return_type, f.name, tys)); } let module = crate::inprocess::parse_ir_text(context, &skeleton, "perry_native_module")?; - let (typed_insts, raw_insts) = stream_functions(context, &module, &funcs, false)?; + let gc_leaf_callees = crate::gc_call_effects::transitive_leaf_functions(&funcs); + let (typed_insts, raw_insts) = + stream_functions(context, &module, &funcs, false, &gc_leaf_callees)?; log::debug!( "perry-codegen: native construction built {} functions, {} typed + {} raw instructions \ (ratchet: raw -> 0), skeleton {} bytes", @@ -108,6 +110,7 @@ fn stream_functions<'ctx>( module: &Module<'ctx>, funcs: &[&crate::function::LlFunction], force_external: bool, + gc_leaf_callees: &std::collections::HashSet, ) -> Result<(usize, usize)> { let mut typed_insts = 0usize; let mut raw_insts = 0usize; @@ -123,7 +126,7 @@ fn stream_functions<'ctx>( // This remains native construction: only one finalized function // is materialized and fed through the closed dialect line reader, // never parsed as module-scale IR. - let fn_text = f.to_ir(); + let fn_text = f.to_ir_with_gc_leaf_callees(gc_leaf_callees); for line in fn_text.lines().skip(1) { stream.line(line).map_err(|e| { anyhow!( @@ -217,7 +220,12 @@ fn freeze_unit( part: &crate::module::OwnedCodegenUnitPart, external_declarations: &[(String, String)], ) -> Result { - let crate::module::OwnedCodegenUnitPart { pre, post, funcs } = part; + let crate::module::OwnedCodegenUnitPart { + pre, + post, + funcs, + gc_leaf_callees, + } = part; let mut skeleton = format!("{pre}{post}"); // Text units minimize declarations with a rendered-reference scan. Typed // instructions can name helpers without passing through that textual scan @@ -248,7 +256,10 @@ fn freeze_unit( // no inkwell builders. Let LLVM's in-process assembly parser build // only these exceptional functions; all ordinary bodies remain on // the typed C-API path and never become text. - skeleton.push_str(&crate::module::render_fn_external(f)); + skeleton.push_str(&crate::module::render_fn_external_with_gc_leaf_callees( + &f, + &gc_leaf_callees, + )); skeleton.push('\n'); continue; } @@ -260,7 +271,7 @@ fn freeze_unit( // owned lines so worker threads still receive an immutable payload // and the module-scale text graph is never retained. items.extend( - f.to_ir() + f.to_ir_with_gc_leaf_callees(&gc_leaf_callees) .lines() .skip(1) .filter(|line| *line != "}") @@ -1045,6 +1056,80 @@ mod tests { ); } + /// #8596: whole-module generated-callee effects must reach both emission + /// transports. The text path spells the string attribute inline; LLVM's + /// C API prints it through an attribute group. RS4GC is the final arbiter: + /// both forms must leave `pure_generated` direct and wrap `may_collect`. + #[test] + fn transitive_generated_leaf_calls_match_text_and_native_construction() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let mut module = LlModule::new(crate::codegen::default_target_triple()); + module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); + module.declare_function("may_collect", VOID, &[]); + + let pure = module.define_function("pure_generated", VOID, vec![]); + pure.create_block("entry").ret_void(); + + let caller = module.define_function("rooted_leaf_caller", VOID, vec![]); + caller.enable_shadow_frame(0); + let slot = caller.reserve_shadow_slot().expect("reserve native root"); + let root = caller.alloca_entry(I64); + caller.entry_allocas_push_store(I64, "0", &root); + caller.entry_setup_call_void( + "js_shadow_slot_bind", + &[(I32, &slot.to_string()), (PTR, &root)], + ); + let entry = caller.create_block("entry"); + entry.call_void("pure_generated", &[]); + entry.call_void("may_collect", &[]); + entry.ret_void(); + + let text_ir = module.to_ir(); + let context = Context::create(); + let native_ir = build_native_module(&context, &module) + .expect("native transitive-leaf witness constructs") + .print_to_string() + .to_string(); + assert!( + text_ir.contains("call void @pure_generated() \"gc-leaf-function\""), + "text path lost transitive leaf marker:\n{text_ir}" + ); + assert!( + native_ir.contains("\"gc-leaf-function\""), + "native path lost transitive leaf marker:\n{native_ir}" + ); + let units = module.render_codegen_units(2); + assert_eq!(units.len(), 2, "fixture must split into two real units"); + assert!( + units + .iter() + .any(|unit| unit.contains("call void @pure_generated() \"gc-leaf-function\"")), + "split text units lost the whole-module leaf closure:\n{}", + units.join("\n--- unit ---\n") + ); + + let target = crate::codegen::default_target_triple(); + for (arm, ir) in [("text", text_ir), ("native", native_ir)] { + let rewritten = crate::inprocess::statepoint_rewritten_ir( + &ir, + &target, + &format!("transitive_leaf_{arm}"), + ) + .unwrap_or_else(|e| panic!("{arm} transitive-leaf witness failed RS4GC: {e:#}")); + assert!( + rewritten.contains("call void @pure_generated()"), + "{arm} path statepointed a proven leaf call:\n{rewritten}" + ); + assert!( + rewritten.lines().any(|line| { + line.contains("@llvm.experimental.gc.statepoint") + && line.contains("@may_collect") + }), + "{arm} path failed to statepoint the collecting control:\n{rewritten}" + ); + } + } + fn compact_gc_map_section_name() -> &'static [u8] { if cfg!(target_os = "macos") { b"__perry_gcmap" diff --git a/crates/perry-codegen/src/native_root_coverage/mechanics.rs b/crates/perry-codegen/src/native_root_coverage/mechanics.rs index b54d8e6034..24de4b8f76 100644 --- a/crates/perry-codegen/src/native_root_coverage/mechanics.rs +++ b/crates/perry-codegen/src/native_root_coverage/mechanics.rs @@ -336,7 +336,15 @@ fn no_entry_module_root_is_live_before_the_gc_is_initialized() { vec![ let_stmt(1, "a", Expr::MapNew), let_stmt(2, "b", Expr::MapNew), - console_log(vec![Expr::LocalGet(1), Expr::LocalGet(2)]), + // Keep the string initializer non-empty: #8596 can prove an + // empty initializer transitively leaf, in which case it is an + // ordinary call rather than a statepoint and this test would + // stop observing the boundary whose ordering it verifies. + console_log(vec![ + Expr::LocalGet(1), + Expr::LocalGet(2), + Expr::String("root-order-control".to_string()), + ]), ], ); let ir = native_ir(&module, target, true); diff --git a/crates/perry-codegen/tests/ios_platform_api_lowering.rs b/crates/perry-codegen/tests/ios_platform_api_lowering.rs new file mode 100644 index 0000000000..1ec033d175 --- /dev/null +++ b/crates/perry-codegen/tests/ios_platform_api_lowering.rs @@ -0,0 +1,166 @@ +//! Regression coverage for the iOS-only `perry/ios` table (#5536). + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Stmt}; + +fn options(target: Option<&str>) -> CompileOptions { + CompileOptions { + target: target.map(str::to_string), + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: Default::default(), + import_function_ffi_aliases: Default::default(), + import_function_origin_names: Default::default(), + import_function_v8_specifiers: Default::default(), + import_function_node_submodule: Default::default(), + namespace_node_submodules: Default::default(), + namespace_v8_specifiers: Default::default(), + namespace_member_prefixes: Default::default(), + namespace_member_origin_names: Default::default(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + namespace_member_nested: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: Default::default(), + type_aliases: Default::default(), + imported_func_param_counts: Default::default(), + imported_func_has_rest: Default::default(), + imported_func_synthetic_arguments: Default::default(), + imported_func_return_types: Default::default(), + imported_vars: Default::default(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: true, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: Default::default(), + nextjs_path_init_modules: Vec::new(), + deferred_module_prefixes: Default::default(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn call(method: &str, args: Vec) -> Stmt { + Stmt::Expr(Expr::NativeMethodCall { + module: "perry/ios".to_string(), + class_name: None, + object: None, + method: method.to_string(), + args, + }) +} + +fn module(body: Vec) -> Module { + Module { + name: "ios_platform_api_probe".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: Default::default(), + closure_display_names: Default::default(), + class_display_names: Default::default(), + closure_source_text: Default::default(), + async_generator_funcs: Default::default(), + local_source_spans: Default::default(), + gen_param_prologue_len: Default::default(), + } +} + +#[test] +fn ios_layout_and_foundation_model_calls_emit_runtime_symbols() { + let hir = module(vec![ + call("getLayoutEnvironment", vec![]), + call("onLayoutChange", vec![Expr::Number(0.0)]), + call("offLayoutChange", vec![Expr::Number(1.0)]), + call("foundationModelAvailability", vec![]), + // The optional instructions argument must pad to an empty runtime string. + call("createLanguageModelSession", vec![]), + call( + "respond", + vec![Expr::Number(1.0), Expr::String("Hello".to_string())], + ), + call("destroyLanguageModelSession", vec![Expr::Number(1.0)]), + ]); + let ir = + String::from_utf8(compile_module(&hir, options(Some("aarch64-apple-ios17.0"))).unwrap()) + .unwrap(); + + for symbol in [ + "@perry_ios_get_layout_environment", + "@perry_ios_on_layout_change", + "@perry_ios_off_layout_change", + "@perry_ios_foundation_model_availability", + "@perry_ios_foundation_model_session_create", + "@perry_ios_foundation_model_respond", + "@perry_ios_foundation_model_session_destroy", + ] { + assert!(ir.contains(symbol), "missing {symbol} in IR:\n{ir}"); + } +} + +#[test] +fn ios_module_is_rejected_for_non_ios_targets() { + let error = compile_module( + &module(vec![call("getLayoutEnvironment", vec![])]), + options(Some("aarch64-apple-darwin")), + ) + .unwrap_err(); + let error = format!("{error:#}"); + assert!( + error.contains("perry/ios is only available"), + "unexpected diagnostic: {error}" + ); +} diff --git a/crates/perry-dispatch/src/ios_table.rs b/crates/perry-dispatch/src/ios_table.rs new file mode 100644 index 0000000000..9bfe915a25 --- /dev/null +++ b/crates/perry-dispatch/src/ios_table.rs @@ -0,0 +1,51 @@ +//! `PERRY_IOS_TABLE` — iOS-specific adaptive layout and Foundation Models. + +use super::*; + +/// APIs that intentionally expose iOS-only platform capabilities. Keeping +/// these out of `PERRY_UI_TABLE` prevents other UI backends from having to +/// pretend that UIKit scene geometry or Foundation Models exist. +pub static PERRY_IOS_TABLE: &[MethodRow] = &[ + MethodRow { + method: "getLayoutEnvironment", + runtime: "perry_ios_get_layout_environment", + args: &[], + ret: ReturnKind::Widget, + }, + MethodRow { + method: "onLayoutChange", + runtime: "perry_ios_on_layout_change", + args: &[ArgKind::Closure], + ret: ReturnKind::I64AsF64, + }, + MethodRow { + method: "offLayoutChange", + runtime: "perry_ios_off_layout_change", + args: &[ArgKind::F64], + ret: ReturnKind::Void, + }, + MethodRow { + method: "foundationModelAvailability", + runtime: "perry_ios_foundation_model_availability", + args: &[], + ret: ReturnKind::Str, + }, + MethodRow { + method: "createLanguageModelSession", + runtime: "perry_ios_foundation_model_session_create", + args: &[ArgKind::Str], + ret: ReturnKind::I64AsF64, + }, + MethodRow { + method: "respond", + runtime: "perry_ios_foundation_model_respond", + args: &[ArgKind::F64, ArgKind::Str], + ret: ReturnKind::Promise, + }, + MethodRow { + method: "destroyLanguageModelSession", + runtime: "perry_ios_foundation_model_session_destroy", + args: &[ArgKind::F64], + ret: ReturnKind::Void, + }, +]; diff --git a/crates/perry-dispatch/src/lib.rs b/crates/perry-dispatch/src/lib.rs index 8e10c99d3e..7026ca6e7e 100644 --- a/crates/perry-dispatch/src/lib.rs +++ b/crates/perry-dispatch/src/lib.rs @@ -97,6 +97,7 @@ pub struct MethodRow { mod audio_table; mod background_table; mod i18n_table; +mod ios_table; mod media_table; mod system_table; mod ui_instance_table; @@ -106,6 +107,7 @@ mod updater_table; pub use audio_table::PERRY_AUDIO_TABLE; pub use background_table::PERRY_BACKGROUND_TABLE; pub use i18n_table::PERRY_I18N_TABLE; +pub use ios_table::PERRY_IOS_TABLE; pub use media_table::PERRY_MEDIA_TABLE; pub use system_table::PERRY_SYSTEM_TABLE; pub use ui_instance_table::PERRY_UI_INSTANCE_TABLE; @@ -134,6 +136,11 @@ pub fn perry_i18n_lookup(method: &str) -> Option<&'static MethodRow> { PERRY_I18N_TABLE.iter().find(|s| s.method == method) } +/// Look up a TS method name in the iOS-only platform table. +pub fn perry_ios_lookup(method: &str) -> Option<&'static MethodRow> { + PERRY_IOS_TABLE.iter().find(|s| s.method == method) +} + /// Look up a TS method name in the perry/updater table. pub fn perry_updater_lookup(method: &str) -> Option<&'static MethodRow> { PERRY_UPDATER_TABLE.iter().find(|s| s.method == method) diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 7f2ba922e1..e889b36a44 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -1599,6 +1599,15 @@ fn queue_thread_result( owner: crate::agent::AgentId, promise_usize: usize, result: SerializedValue, +) { + queue_thread_result_with_mode(owner, promise_usize, result, false); +} + +fn queue_thread_result_with_mode( + owner: crate::agent::AgentId, + promise_usize: usize, + result: SerializedValue, + is_rejection: bool, ) { // We need to interact with perry-stdlib's deferred resolution queue. // Since perry-runtime cannot depend on perry-stdlib, we use the same @@ -1616,6 +1625,7 @@ fn queue_thread_result( owner, promise_ptr: promise_usize, result, + is_rejection, }); } ACTIVE_THREAD_JOBS.fetch_sub(1, Ordering::SeqCst); @@ -1664,6 +1674,23 @@ pub fn queue_promise_string_result( ); } +/// Reject a pinned cross-thread promise with a UTF-8 message on its owning +/// agent. This is the error-side companion to +/// [`queue_promise_string_result`], used by native async framework bridges +/// whose completion may arrive on an arbitrary OS thread (#5536). +pub fn queue_promise_string_rejection( + owner: crate::agent::AgentId, + promise_usize: usize, + message: &str, +) { + queue_thread_result_with_mode( + owner, + promise_usize, + SerializedValue::String(message.as_bytes().to_vec()), + true, + ); +} + /// A pending thread result waiting to be resolved on the agent that spawned it. struct PendingThreadResult { /// #6185: the agent whose heap `promise_ptr` lives in — captured at spawn @@ -1674,6 +1701,8 @@ struct PendingThreadResult { owner: crate::agent::AgentId, promise_ptr: usize, result: SerializedValue, + /// Settle through `reject` rather than `resolve` after deserialization. + is_rejection: bool, } // Safety: SerializedValue is Send, usize is Send. `promise_ptr` is a raw @@ -1689,7 +1718,7 @@ static PENDING_THREAD_RESULTS: std::sync::Mutex> = /// (registered as a pump function, similar to js_stdlib_process_pending). /// /// Drains the queue, deserializes each result into the main thread's arena, -/// and resolves the corresponding Promise. +/// and resolves or rejects the corresponding Promise. /// /// # Returns /// Number of results processed. @@ -1737,9 +1766,13 @@ pub extern "C" fn js_thread_process_pending() -> i32 { continue; } - // Deserialize the result into the main thread's arena and resolve. + // Deserialize the result into the owning agent's arena and settle. let result_bits = deserialize_nanbox_on_current_thread(&item.result); - crate::promise::js_promise_resolve(promise, f64::from_bits(result_bits)); + if item.is_rejection { + crate::promise::js_promise_reject(promise, f64::from_bits(result_bits)); + } else { + crate::promise::js_promise_resolve(promise, f64::from_bits(result_bits)); + } } } diff --git a/crates/perry-ui-ios/Cargo.toml b/crates/perry-ui-ios/Cargo.toml index 7ebbb4fba5..473ace5027 100644 --- a/crates/perry-ui-ios/Cargo.toml +++ b/crates/perry-ui-ios/Cargo.toml @@ -55,6 +55,7 @@ objc2-ui-kit = { version = "0.3", features = [ "UIColor", "UIFont", "UIControl", + "UIGeometry", "UIPasteboard", "UIResponder", "UIScreen", diff --git a/crates/perry-ui-ios/src/adaptive_layout.rs b/crates/perry-ui-ios/src/adaptive_layout.rs new file mode 100644 index 0000000000..b7c3cb4f7e --- /dev/null +++ b/crates/perry-ui-ios/src/adaptive_layout.rs @@ -0,0 +1,403 @@ +//! Scene-relative adaptive-layout information for `perry/ios` (#5536). +//! +//! UIKit's window size, traits, and safe area are the stable public signals +//! for foldable-sized displays, iPad Split View, and Stage Manager. Device +//! model checks are intentionally avoided: a single scene can move through +//! all of these layouts without the hardware changing. + +use objc2::msg_send; +use objc2::runtime::{AnyObject, Sel}; +use objc2_core_foundation::CGRect; +use objc2_ui_kit::UIEdgeInsets; +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicI64, Ordering}; + +extern "C" { + fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void; + fn js_object_set_field_by_name( + obj: *mut c_void, + key: *const perry_runtime::string::StringHeader, + value: f64, + ); + fn js_string_from_bytes(data: *const u8, len: u32) -> *mut perry_runtime::string::StringHeader; + fn js_nanbox_pointer(ptr: i64) -> f64; + fn js_nanbox_string(ptr: i64) -> f64; + fn js_nanbox_get_pointer(value: f64) -> i64; + fn js_closure_call1(closure: *const u8, arg: f64) -> f64; + fn js_run_stdlib_pump(); + fn js_promise_run_microtasks() -> i32; +} + +const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; +const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + +fn zero_insets() -> UIEdgeInsets { + UIEdgeInsets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct LayoutSnapshot { + width: f64, + height: f64, + aspect_ratio: f64, + display_scale: f64, + horizontal_size_class: &'static str, + vertical_size_class: &'static str, + orientation: &'static str, + window_mode: &'static str, + is_multitasking: bool, + is_four_by_three: bool, + system_frame_x: f64, + system_frame_y: f64, + system_frame_width: f64, + system_frame_height: f64, + is_interactively_resizing: bool, + is_interface_orientation_locked: bool, + safe_area: UIEdgeInsets, +} + +thread_local! { + static LISTENERS: RefCell> = RefCell::new(HashMap::new()); + static LAST_SNAPSHOT: RefCell> = const { RefCell::new(None) }; +} + +static NEXT_LISTENER_ID: AtomicI64 = AtomicI64::new(1); + +fn size_class_name(value: isize) -> &'static str { + match value { + 1 => "compact", + 2 => "regular", + _ => "unspecified", + } +} + +fn nearly_equal(a: f64, b: f64) -> bool { + (a - b).abs() <= 2.0 +} + +fn classify_window_mode( + width: f64, + height: f64, + screen_width: f64, + screen_height: f64, + is_pad: bool, +) -> (&'static str, bool) { + let fills_width = nearly_equal(width, screen_width); + let fills_height = nearly_equal(height, screen_height); + if fills_width && fills_height { + return ("fullScreen", false); + } + + let multitasking = is_pad && (!fills_width || !fills_height); + if multitasking && fills_height && width + 2.0 < screen_width { + ("sideBySide", true) + } else { + ("windowed", multitasking) + } +} + +fn is_four_by_three(width: f64, height: f64) -> bool { + let short = width.min(height); + let long = width.max(height); + short > 0.0 && ((long / short) - (4.0 / 3.0)).abs() <= 0.04 +} + +fn current_snapshot() -> Option { + crate::app::APPS.with(|apps| { + let apps = apps.borrow(); + let window = &apps.last()?.window; + unsafe { + let bounds: CGRect = msg_send![&**window, bounds]; + let screen: *mut AnyObject = msg_send![&**window, screen]; + let screen_bounds: CGRect = if screen.is_null() { + bounds + } else { + msg_send![screen, bounds] + }; + let scale: f64 = if screen.is_null() { + 1.0 + } else { + msg_send![screen, scale] + }; + + // iOS 27's effectiveGeometry is the authoritative scene frame and + // interactive-resize state. Selectors keep this binary compatible + // with earlier deployment targets and SDK-built UI archives. + let scene: *mut AnyObject = msg_send![&**window, windowScene]; + let effective_geometry_selector = Sel::register(c"effectiveGeometry"); + let has_effective_geometry = !scene.is_null() + && msg_send![scene, respondsToSelector: effective_geometry_selector]; + let effective_geometry: *mut AnyObject = if has_effective_geometry { + msg_send![scene, effectiveGeometry] + } else { + std::ptr::null_mut() + }; + let window_frame: CGRect = msg_send![&**window, frame]; + let system_frame: CGRect = if effective_geometry.is_null() { + window_frame + } else { + msg_send![effective_geometry, systemFrame] + }; + let interactive_selector = Sel::register(c"isInteractivelyResizing"); + let is_interactively_resizing = !effective_geometry.is_null() + && msg_send![effective_geometry, respondsToSelector: interactive_selector] + && msg_send![effective_geometry, isInteractivelyResizing]; + let orientation_locked_selector = Sel::register(c"isInterfaceOrientationLocked"); + let is_interface_orientation_locked = !effective_geometry.is_null() + && msg_send![effective_geometry, respondsToSelector: orientation_locked_selector] + && msg_send![effective_geometry, isInterfaceOrientationLocked]; + + let traits: *mut AnyObject = msg_send![&**window, traitCollection]; + let horizontal: isize = if traits.is_null() { + 0 + } else { + msg_send![traits, horizontalSizeClass] + }; + let vertical: isize = if traits.is_null() { + 0 + } else { + msg_send![traits, verticalSizeClass] + }; + let idiom: isize = if traits.is_null() { + -1 + } else { + msg_send![traits, userInterfaceIdiom] + }; + + let root: *mut AnyObject = msg_send![&**window, rootViewController]; + let safe_area = if root.is_null() { + zero_insets() + } else { + let view: *mut AnyObject = msg_send![root, view]; + if view.is_null() { + zero_insets() + } else { + msg_send![view, safeAreaInsets] + } + }; + + let width = bounds.size.width.max(0.0); + let height = bounds.size.height.max(0.0); + let aspect_ratio = if height > 0.0 { width / height } else { 0.0 }; + let orientation = if nearly_equal(width, height) { + "square" + } else if width > height { + "landscape" + } else { + "portrait" + }; + let (window_mode, is_multitasking) = classify_window_mode( + system_frame.size.width, + system_frame.size.height, + screen_bounds.size.width, + screen_bounds.size.height, + idiom == 1, // UIUserInterfaceIdiomPad + ); + + Some(LayoutSnapshot { + width, + height, + aspect_ratio, + display_scale: scale, + horizontal_size_class: size_class_name(horizontal), + vertical_size_class: size_class_name(vertical), + orientation, + window_mode, + is_multitasking, + is_four_by_three: is_four_by_three(width, height), + system_frame_x: system_frame.origin.x, + system_frame_y: system_frame.origin.y, + system_frame_width: system_frame.size.width, + system_frame_height: system_frame.size.height, + is_interactively_resizing, + is_interface_orientation_locked, + safe_area, + }) + } + }) +} + +unsafe fn string_value(value: &str) -> f64 { + let ptr = js_string_from_bytes(value.as_ptr(), value.len() as u32); + js_nanbox_string(ptr as i64) +} + +fn bool_value(value: bool) -> f64 { + f64::from_bits(if value { TAG_TRUE } else { TAG_FALSE }) +} + +unsafe fn set_field(object: *mut c_void, name: &str, value: f64) { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_set_field_by_name(object, key, value); +} + +unsafe fn snapshot_object(snapshot: &LayoutSnapshot) -> i64 { + let object = js_object_alloc(0, 20); + if object.is_null() { + return 0; + } + set_field(object, "width", snapshot.width); + set_field(object, "height", snapshot.height); + set_field(object, "aspectRatio", snapshot.aspect_ratio); + set_field(object, "displayScale", snapshot.display_scale); + set_field( + object, + "horizontalSizeClass", + string_value(snapshot.horizontal_size_class), + ); + set_field( + object, + "verticalSizeClass", + string_value(snapshot.vertical_size_class), + ); + set_field(object, "orientation", string_value(snapshot.orientation)); + set_field(object, "windowMode", string_value(snapshot.window_mode)); + set_field( + object, + "isMultitasking", + bool_value(snapshot.is_multitasking), + ); + set_field( + object, + "isFourByThree", + bool_value(snapshot.is_four_by_three), + ); + set_field(object, "systemFrameX", snapshot.system_frame_x); + set_field(object, "systemFrameY", snapshot.system_frame_y); + set_field(object, "systemFrameWidth", snapshot.system_frame_width); + set_field(object, "systemFrameHeight", snapshot.system_frame_height); + set_field( + object, + "isInteractivelyResizing", + bool_value(snapshot.is_interactively_resizing), + ); + set_field( + object, + "isInterfaceOrientationLocked", + bool_value(snapshot.is_interface_orientation_locked), + ); + set_field(object, "safeAreaTop", snapshot.safe_area.top); + set_field(object, "safeAreaRight", snapshot.safe_area.right); + set_field(object, "safeAreaBottom", snapshot.safe_area.bottom); + set_field(object, "safeAreaLeft", snapshot.safe_area.left); + object as i64 +} + +unsafe fn invoke_listener(callback: f64, snapshot: &LayoutSnapshot) { + let closure = js_nanbox_get_pointer(callback) as *const u8; + if closure.is_null() { + return; + } + let object = snapshot_object(snapshot); + if object == 0 { + return; + } + js_run_stdlib_pump(); + js_closure_call1(closure, js_nanbox_pointer(object)); + js_promise_run_microtasks(); +} + +/// Called after UIKit lays out the root controller and after scene creation. +/// Duplicate layouts are suppressed so animation/layout passes don't flood JS. +pub(crate) fn notify_if_changed() { + let Some(snapshot) = current_snapshot() else { + return; + }; + let changed = LAST_SNAPSHOT.with(|last| { + let mut last = last.borrow_mut(); + if last.as_ref() == Some(&snapshot) { + false + } else { + *last = Some(snapshot.clone()); + true + } + }); + if !changed { + return; + } + let callbacks = + LISTENERS.with(|listeners| listeners.borrow().values().copied().collect::>()); + for callback in callbacks { + unsafe { invoke_listener(callback, &snapshot) }; + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_get_layout_environment() -> i64 { + let snapshot = current_snapshot().unwrap_or(LayoutSnapshot { + width: 0.0, + height: 0.0, + aspect_ratio: 0.0, + display_scale: 1.0, + horizontal_size_class: "unspecified", + vertical_size_class: "unspecified", + orientation: "square", + window_mode: "windowed", + is_multitasking: false, + is_four_by_three: false, + system_frame_x: 0.0, + system_frame_y: 0.0, + system_frame_width: 0.0, + system_frame_height: 0.0, + is_interactively_resizing: false, + is_interface_orientation_locked: false, + safe_area: zero_insets(), + }); + unsafe { snapshot_object(&snapshot) } +} + +#[no_mangle] +pub extern "C" fn perry_ios_on_layout_change(callback: f64) -> i64 { + let id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed); + LISTENERS.with(|listeners| { + listeners.borrow_mut().insert(id, callback); + }); + if let Some(snapshot) = current_snapshot() { + unsafe { invoke_listener(callback, &snapshot) }; + } + id +} + +#[no_mangle] +pub extern "C" fn perry_ios_off_layout_change(subscription: f64) { + if subscription.is_finite() && subscription > 0.0 { + LISTENERS.with(|listeners| { + listeners.borrow_mut().remove(&(subscription as i64)); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_fullscreen_split_and_windowed_scenes() { + assert_eq!( + classify_window_mode(1024.0, 1366.0, 1024.0, 1366.0, true), + ("fullScreen", false) + ); + assert_eq!( + classify_window_mode(507.0, 1366.0, 1024.0, 1366.0, true), + ("sideBySide", true) + ); + assert_eq!( + classify_window_mode(800.0, 1000.0, 1024.0, 1366.0, true), + ("windowed", true) + ); + } + + #[test] + fn detects_four_by_three_in_both_orientations() { + assert!(is_four_by_three(1024.0, 768.0)); + assert!(is_four_by_three(768.0, 1024.0)); + assert!(!is_four_by_three(390.0, 844.0)); + } +} diff --git a/crates/perry-ui-ios/src/app.rs b/crates/perry-ui-ios/src/app.rs index 44a086a0b5..6eb2d7a651 100644 --- a/crates/perry-ui-ios/src/app.rs +++ b/crates/perry-ui-ios/src/app.rs @@ -287,6 +287,10 @@ unsafe extern "C" fn scene_will_connect( a.borrow_mut().push(AppEntry { window }); }); + // Publish the initial scene-relative geometry after the UIWindow has a + // root controller and is visible (#5536). + crate::adaptive_layout::notify_if_changed(); + // Register for keyboard notifications register_keyboard_observers(); @@ -371,6 +375,17 @@ unsafe extern "C" fn scene_continue_user_activity( crate::deeplinks::dispatch_continue_user_activity(activity as *const AnyObject); } +/// iOS 27 scene-geometry callback. UIKit passes the previous geometry; the +/// public Perry snapshot always reads the scene's current effective geometry. +unsafe extern "C" fn scene_did_update_effective_geometry( + _this: *mut AnyObject, + _sel: *const std::ffi::c_void, + _scene: *mut AnyObject, + _previous_geometry: *mut AnyObject, +) { + crate::adaptive_layout::notify_if_changed(); +} + /// Register the PerrySceneDelegate class dynamically at runtime. fn register_scene_delegate() { unsafe { @@ -422,6 +437,17 @@ fn register_scene_delegate() { c"v@:@@".as_ptr(), ); + // iOS 27: receive every effective UIWindowScene geometry update, + // including continuous interactive resizing and screen moves. Older + // UIKit releases simply never invoke this optional delegate method. + let sel_geometry = sel_registerName(c"windowScene:didUpdateEffectiveGeometry:".as_ptr()); + class_addMethod( + cls, + sel_geometry, + scene_did_update_effective_geometry as *const std::ffi::c_void, + c"v@:@@".as_ptr(), + ); + objc_registerClassPair(cls); } } @@ -463,6 +489,19 @@ unsafe extern "C" fn vc_can_perform_action( action == perry_sel } +/// UIViewController layout hook used by `perry/ios.onLayoutChange`. Calling +/// super preserves UIKit's own controller layout before we snapshot bounds, +/// traits, and safe-area insets. +unsafe extern "C" fn vc_view_did_layout_subviews( + this: *mut AnyObject, + _sel: *const std::ffi::c_void, +) { + if let Some(superclass) = AnyClass::get(c"UIViewController") { + let _: () = msg_send![super(this, superclass), viewDidLayoutSubviews]; + } + crate::adaptive_layout::notify_if_changed(); +} + /// Register the PerryViewController class dynamically at runtime. fn register_view_controller() { unsafe { @@ -500,6 +539,14 @@ fn register_view_controller() { c"B@::@".as_ptr(), ); + let sel_layout = sel_registerName(c"viewDidLayoutSubviews".as_ptr()); + class_addMethod( + cls, + sel_layout, + vc_view_did_layout_subviews as *const std::ffi::c_void, + c"v@:".as_ptr(), + ); + objc_registerClassPair(cls); } } diff --git a/crates/perry-ui-ios/src/foundation_models.rs b/crates/perry-ui-ios/src/foundation_models.rs new file mode 100644 index 0000000000..5659e1fa49 --- /dev/null +++ b/crates/perry-ui-ios/src/foundation_models.rs @@ -0,0 +1,127 @@ +//! Swift Foundation Models bridge for `perry/ios` (#5536). +//! +//! Foundation Models is Swift-only, so the final iOS link compiles the small +//! companion in `swift/PerryFoundationModels.swift`. This Rust side owns the +//! Perry ABI, UTF-8 conversion, Promise lifetime, and owner-agent handoff. + +use perry_ffi::copy_string_from_raw as str_from_header; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +type Completion = unsafe extern "C" fn(i64, bool, *const u8, i32); + +extern "C" { + fn perry_swift_foundation_model_availability() -> i32; + fn perry_swift_foundation_model_session_create(bytes: *const u8, len: i32) -> i64; + fn perry_swift_foundation_model_session_destroy(session: i64); + fn perry_swift_foundation_model_respond( + session: i64, + bytes: *const u8, + len: i32, + context: i64, + completion: Completion, + ); + fn js_string_from_bytes(bytes: *const u8, len: u32) + -> *mut perry_runtime::string::StringHeader; +} + +/// Promise address → owner agent. The Promise itself is malloc-space pinned +/// until `js_thread_process_pending` settles the queued completion. +static PENDING_RESPONSES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn lock_pending() -> std::sync::MutexGuard<'static, HashMap> { + match PENDING_RESPONSES.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn runtime_string(value: &str) -> i64 { + unsafe { js_string_from_bytes(value.as_ptr(), value.len() as u32) as i64 } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_availability() -> i64 { + let value = unsafe { + match perry_swift_foundation_model_availability() { + 1 => "available", + 2 => "deviceNotEligible", + 3 => "appleIntelligenceNotEnabled", + 4 => "modelNotReady", + _ => "unsupported", + } + }; + runtime_string(value) +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_session_create(instructions_ptr: i64) -> i64 { + let instructions = if instructions_ptr == 0 { + String::new() + } else { + unsafe { str_from_header(instructions_ptr as *const u8) }.to_string() + }; + unsafe { + perry_swift_foundation_model_session_create( + instructions.as_ptr(), + instructions.len() as i32, + ) + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_session_destroy(session: f64) { + if session.is_finite() && session > 0.0 { + unsafe { perry_swift_foundation_model_session_destroy(session as i64) }; + } +} + +unsafe extern "C" fn response_completion(context: i64, success: bool, bytes: *const u8, len: i32) { + let Some(owner) = lock_pending().remove(&context) else { + return; + }; + let value = if bytes.is_null() || len <= 0 { + String::new() + } else { + String::from_utf8_lossy(std::slice::from_raw_parts(bytes, len as usize)).into_owned() + }; + if success { + perry_runtime::thread::queue_promise_string_result(owner, context as usize, &value); + } else { + perry_runtime::thread::queue_promise_string_rejection(owner, context as usize, &value); + } +} + +#[no_mangle] +pub extern "C" fn perry_ios_foundation_model_respond(session: f64, prompt_ptr: i64) -> i64 { + let prompt = if prompt_ptr == 0 { + String::new() + } else { + unsafe { str_from_header(prompt_ptr as *const u8) }.to_string() + }; + + // The Swift task can outlive every JS reference to the returned promise. + // Force malloc-space allocation and pin it until its owner-agent queue + // drains the result; this is the same protocol used by spawn/waitAsync. + let promise = perry_runtime::promise::js_promise_new_cross_thread(); + unsafe { perry_runtime::thread::pin_promise(promise) }; + perry_runtime::thread::thread_job_begin(); + let context = promise as i64; + lock_pending().insert(context, perry_runtime::agent::current_agent()); + + unsafe { + perry_swift_foundation_model_respond( + if session.is_finite() && session > 0.0 { + session as i64 + } else { + 0 + }, + prompt.as_ptr(), + prompt.len() as i32, + context, + response_completion, + ); + } + context +} diff --git a/crates/perry-ui-ios/src/lib.rs b/crates/perry-ui-ios/src/lib.rs index b37b4d4c59..142064c064 100644 --- a/crates/perry-ui-ios/src/lib.rs +++ b/crates/perry-ui-ios/src/lib.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "ios")] +pub mod adaptive_layout; pub mod app; pub mod audio; pub mod audio_playback; @@ -10,6 +11,7 @@ pub mod crash_log; pub mod deeplinks; pub mod drag_drop; pub mod file_dialog; +pub mod foundation_models; pub mod geolocation; pub mod image_picker; pub mod keyboard; diff --git a/crates/perry-ui-ios/src/media_playback.rs b/crates/perry-ui-ios/src/media_playback.rs index 77b23a7ce0..3307c52c07 100644 --- a/crates/perry-ui-ios/src/media_playback.rs +++ b/crates/perry-ui-ios/src/media_playback.rs @@ -15,15 +15,18 @@ //! `AVPlayerItemDidPlayToEndTimeNotification`. A 10 Hz `NSTimer` drives //! both the state-change callback (on transition) and the time-update //! callback (every tick while playing/loading). -//! - Now Playing metadata uses `MPNowPlayingInfoCenter`. Lock-screen / Touch -//! Bar / Siri Remote play/pause/skip routes through `MPRemoteCommandCenter`. +//! - iOS 27 uses the Swift `NowPlaying` framework's observable `MediaSession`. +//! Earlier SDKs/OS versions retain the `MPNowPlayingInfoCenter` and +//! `MPRemoteCommandCenter` implementation as a compatibility fallback. use objc2::msg_send; use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject, Sel}; use std::cell::RefCell; +use std::ffi::CStr; use std::ffi::CString; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; extern "C" { fn js_nanbox_get_pointer(value: f64) -> i64; @@ -91,6 +94,18 @@ impl MediaState { MediaState::Error => "error", } } + + fn as_now_playing_code(self) -> i32 { + match self { + MediaState::Idle => 0, + MediaState::Loading => 1, + MediaState::Ready => 2, + MediaState::Playing => 3, + MediaState::Paused => 4, + MediaState::Ended => 5, + MediaState::Error => 6, + } + } } struct PlayerEntry { @@ -130,6 +145,120 @@ fn nsstring(s: &str) -> Retained { objc2_foundation::NSString::from_str(s) } +// --------------------------------------------------------------------------- +// iOS 27 NowPlaying bridge +// --------------------------------------------------------------------------- + +type NowPlayingIsAvailable = unsafe extern "C" fn() -> i32; +type NowPlayingPublish = unsafe extern "C" fn( + i64, + *const u8, + i32, + *const u8, + i32, + *const u8, + i32, + *const u8, + i32, + i32, + f64, + f64, +); +type NowPlayingUpdate = unsafe extern "C" fn(i64, i32, f64, f64); +type NowPlayingRemove = unsafe extern "C" fn(i64); + +static NOW_PLAYING_IS_AVAILABLE: OnceLock> = OnceLock::new(); +static NOW_PLAYING_PUBLISH: OnceLock> = OnceLock::new(); +static NOW_PLAYING_UPDATE: OnceLock> = OnceLock::new(); +static NOW_PLAYING_REMOVE: OnceLock> = OnceLock::new(); + +fn dynamic_symbol(cell: &OnceLock>, name: &CStr) -> Option { + *cell.get_or_init(|| unsafe { + let raw = libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()); + if raw.is_null() { + None + } else { + // Mach-O function pointers have pointer width. `T` is one of the + // concrete C function-pointer aliases above. + Some(std::mem::transmute_copy::<*mut libc::c_void, T>(&raw)) + } + }) +} + +fn now_playing_bridge_available() -> bool { + dynamic_symbol( + &NOW_PLAYING_IS_AVAILABLE, + c"perry_swift_now_playing_is_available", + ) + .is_some_and(|function| unsafe { function() != 0 }) +} + +fn publish_now_playing_session( + handle: i64, + title: &str, + artist: &str, + album: &str, + artwork: &str, + state: MediaState, + elapsed_time: f64, + duration: f64, +) -> bool { + if !now_playing_bridge_available() { + return false; + } + let Some(function) = dynamic_symbol(&NOW_PLAYING_PUBLISH, c"perry_swift_now_playing_publish") + else { + return false; + }; + unsafe { + function( + handle, + title.as_ptr(), + title.len() as i32, + artist.as_ptr(), + artist.len() as i32, + album.as_ptr(), + album.len() as i32, + artwork.as_ptr(), + artwork.len() as i32, + state.as_now_playing_code(), + elapsed_time, + duration, + ); + } + true +} + +fn update_now_playing_session(handle: i64, state: MediaState, elapsed_time: f64, duration: f64) { + if !now_playing_bridge_available() { + return; + } + if let Some(function) = dynamic_symbol(&NOW_PLAYING_UPDATE, c"perry_swift_now_playing_update") { + unsafe { function(handle, state.as_now_playing_code(), elapsed_time, duration) }; + } +} + +fn remove_now_playing_session(handle: i64) { + if !now_playing_bridge_available() { + return; + } + if let Some(function) = dynamic_symbol(&NOW_PLAYING_REMOVE, c"perry_swift_now_playing_remove") { + unsafe { function(handle) }; + } +} + +/// Command callback used by the iOS 27 Swift `MediaSession` bridge. +#[no_mangle] +pub extern "C" fn perry_ios_now_playing_command(handle: i64, command: i32, value: f64) { + match command { + 1 => play(handle as f64), + 2 => pause(handle as f64), + 3 => stop(handle as f64), + 4 => seek(handle as f64, value), + _ => {} + } +} + // --------------------------------------------------------------------------- // Public FFI — called from `crates/perry-ui-macos/src/lib.rs` thunks // --------------------------------------------------------------------------- @@ -423,12 +552,40 @@ pub fn set_now_playing( let artist = unsafe { str_from_header(artist_ptr) }; let album = unsafe { str_from_header(album_ptr) }; let artwork = unsafe { str_from_header(artwork_ptr) }; - // The handle is currently advisory — MPNowPlayingInfoCenter is a - // process-wide singleton, so the most recent setNowPlaying wins. - // Holding the handle in the API keeps room for multi-player apps to - // associate metadata with a specific player when we add a remote- - // command dispatch table keyed by handle. - let _ = handle; + + if let Some(index) = handle_to_index(handle) { + let snapshot = PLAYERS.with(|players| { + players.borrow().get(index).and_then(|slot| { + slot.as_ref().map(|entry| { + ( + entry.state, + unsafe { current_time_seconds(&entry.player) }, + entry.duration_seconds, + ) + }) + }) + }); + if let Some((state, elapsed_time, duration)) = snapshot { + if publish_now_playing_session( + handle as i64, + &title, + &artist, + &album, + &artwork, + state, + elapsed_time, + duration, + ) { + // Apple explicitly forbids mixing NowPlaying with the legacy + // MediaPlayer now-playing APIs in one local session. + return; + } + } + } + + // Compatibility path for apps built with an older SDK or running before + // iOS 27. MPNowPlayingInfoCenter is process-wide, so the most recent call + // wins here; the iOS 27 path above is handle-scoped. unsafe { let center_cls = match AnyClass::get(c"MPNowPlayingInfoCenter") { @@ -501,6 +658,7 @@ pub fn destroy(handle: f64) { } } }); + remove_now_playing_session((idx + 1) as i64); } // --------------------------------------------------------------------------- @@ -652,7 +810,7 @@ unsafe extern "C" fn poll_tick( ) { PLAYERS.with(|p| { let mut players = p.borrow_mut(); - for slot in players.iter_mut() { + for (index, slot) in players.iter_mut().enumerate() { let entry = match slot { Some(e) => e, None => continue, @@ -687,6 +845,8 @@ unsafe extern "C" fn poll_tick( let cur = current_time_seconds(&entry.player); let dur = entry.duration_seconds; + update_now_playing_session((index + 1) as i64, new_state, cur, dur); + if let Some(cb) = on_state { fire_state_callback(cb, new_state); } diff --git a/crates/perry-ui-ios/src/widgets/splitview.rs b/crates/perry-ui-ios/src/widgets/splitview.rs index 40dcf8e438..14806bc784 100644 --- a/crates/perry-ui-ios/src/widgets/splitview.rs +++ b/crates/perry-ui-ios/src/widgets/splitview.rs @@ -40,7 +40,12 @@ unsafe extern "C" fn frame_split_layout_subviews( ) { let bounds: objc2_core_foundation::CGRect = objc2::msg_send![this, bounds]; let tag: i64 = objc2::msg_send![this, tag]; - let left_width = tag as f64 / 100.0; + // Keep the detail pane usable when iPad Split View, Stage Manager, or a + // future resizable display makes the scene narrower than the preferred + // sidebar width. This is scene-relative; device-model checks would miss + // size changes while the app is already running. + let preferred_left_width = (tag as f64 / 100.0).max(0.0); + let left_width = preferred_left_width.min((bounds.size.width * 0.45).max(0.0)); let subviews: *mut AnyObject = objc2::msg_send![this, subviews]; let count: usize = objc2::msg_send![subviews, count]; @@ -151,7 +156,8 @@ pub fn frame_split_add_child(parent: &UIView, child: &UIView) { /// Create a plain UIView that lays out exactly two children side by side /// using Auto Layout constraints (not UIStackView). /// -/// The first child added gets a fixed width (left_width) pinned to the left. +/// The first child added gets its preferred width (`left_width`) pinned to the +/// left, capped at 45% of the current scene width for adaptive layouts. /// The second child fills the remaining space on the right. /// This avoids UIStackView layout conflicts with embedded native views. pub fn create(left_width: f64) -> i64 { @@ -169,7 +175,7 @@ pub fn create(left_width: f64) -> i64 { } /// Add a child to a split view container. The first child becomes the left panel -/// (fixed width from tag), the second becomes the right panel (fills remaining). +/// (preferred width from tag), the second becomes the right panel (fills remaining). pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { unsafe { let _: () = msg_send![child, setTranslatesAutoresizingMaskIntoConstraints: false]; @@ -192,7 +198,9 @@ pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { let _: () = msg_send![&*bc, setActive: true]; if child_index == 0 { - // Left panel: pin leading to parent, fixed width + // Left panel: pin leading to parent and prefer the requested width, + // but cap it relative to the live scene width. The lower-priority + // preferred constraint yields in narrow split-screen/window modes. let child_leading: Retained = msg_send![child, leadingAnchor]; let parent_leading: Retained = msg_send![parent, leadingAnchor]; let lc: Retained = @@ -200,9 +208,18 @@ pub fn add_child(parent: &UIView, child: &UIView, child_index: usize) { let _: () = msg_send![&*lc, setActive: true]; let child_width: Retained = msg_send![child, widthAnchor]; - let wc: Retained = + let preferred_width: Retained = msg_send![&*child_width, constraintEqualToConstant: left_width]; - let _: () = msg_send![&*wc, setActive: true]; + let _: () = msg_send![&*preferred_width, setPriority: 750.0f32]; + let _: () = msg_send![&*preferred_width, setActive: true]; + + let parent_width: Retained = msg_send![parent, widthAnchor]; + let adaptive_max: Retained = msg_send![ + &*child_width, + constraintLessThanOrEqualToAnchor: &*parent_width, + multiplier: 0.45f64 + ]; + let _: () = msg_send![&*adaptive_max, setActive: true]; } else { // Right panel: pin trailing to parent, leading to previous sibling's trailing let child_leading: Retained = msg_send![child, leadingAnchor]; diff --git a/crates/perry-ui-ios/swift/PerryFoundationModels.swift b/crates/perry-ui-ios/swift/PerryFoundationModels.swift new file mode 100644 index 0000000000..26f640e5cd --- /dev/null +++ b/crates/perry-ui-ios/swift/PerryFoundationModels.swift @@ -0,0 +1,119 @@ +import Foundation +@_weakLinked import FoundationModels + +public typealias PerryFoundationModelCompletion = @convention(c) ( + Int64, + Bool, + UnsafePointer?, + Int32 +) -> Void + +private func decodeUTF8(_ bytes: UnsafePointer?, _ length: Int32) -> String { + guard let bytes, length > 0 else { return "" } + return String(decoding: UnsafeBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) +} + +private func complete( + _ callback: PerryFoundationModelCompletion, + context: Int64, + success: Bool, + value: String +) { + let bytes = Array(value.utf8) + bytes.withUnsafeBufferPointer { buffer in + callback(context, success, buffer.baseAddress, Int32(buffer.count)) + } +} + +@available(iOS 26.0, *) +private final class PerryLanguageModelSessions: @unchecked Sendable { + static let shared = PerryLanguageModelSessions() + + private let lock = NSLock() + private var nextHandle: Int64 = 1 + private var sessions: [Int64: LanguageModelSession] = [:] + + func create(instructions: String) -> Int64 { + guard SystemLanguageModel.default.isAvailable else { return 0 } + let session = LanguageModelSession( + instructions: instructions.isEmpty ? nil : instructions + ) + lock.lock() + defer { lock.unlock() } + let handle = nextHandle + nextHandle += 1 + sessions[handle] = session + return handle + } + + func session(for handle: Int64) -> LanguageModelSession? { + lock.lock() + defer { lock.unlock() } + return sessions[handle] + } + + func destroy(_ handle: Int64) { + lock.lock() + sessions.removeValue(forKey: handle) + lock.unlock() + } +} + +@_cdecl("perry_swift_foundation_model_availability") +public func perrySwiftFoundationModelAvailability() -> Int32 { + guard #available(iOS 26.0, *) else { return 0 } + switch SystemLanguageModel.default.availability { + case .available: + return 1 + case .unavailable(.deviceNotEligible): + return 2 + case .unavailable(.appleIntelligenceNotEnabled): + return 3 + case .unavailable(.modelNotReady): + return 4 + @unknown default: + return 0 + } +} + +@_cdecl("perry_swift_foundation_model_session_create") +public func perrySwiftFoundationModelSessionCreate( + _ bytes: UnsafePointer?, + _ length: Int32 +) -> Int64 { + guard #available(iOS 26.0, *) else { return 0 } + return PerryLanguageModelSessions.shared.create(instructions: decodeUTF8(bytes, length)) +} + +@_cdecl("perry_swift_foundation_model_session_destroy") +public func perrySwiftFoundationModelSessionDestroy(_ session: Int64) { + guard #available(iOS 26.0, *) else { return } + PerryLanguageModelSessions.shared.destroy(session) +} + +@_cdecl("perry_swift_foundation_model_respond") +public func perrySwiftFoundationModelRespond( + _ sessionHandle: Int64, + _ bytes: UnsafePointer?, + _ length: Int32, + _ context: Int64, + _ callback: PerryFoundationModelCompletion +) { + guard #available(iOS 26.0, *) else { + complete(callback, context: context, success: false, value: "Foundation Models requires iOS 26 or later") + return + } + guard let session = PerryLanguageModelSessions.shared.session(for: sessionHandle) else { + complete(callback, context: context, success: false, value: "Invalid or unavailable Foundation Models session") + return + } + let prompt = decodeUTF8(bytes, length) + Task { + do { + let response = try await session.respond(to: prompt) + complete(callback, context: context, success: true, value: response.content) + } catch { + complete(callback, context: context, success: false, value: String(describing: error)) + } + } +} diff --git a/crates/perry-ui-ios/swift/PerryNowPlaying.swift b/crates/perry-ui-ios/swift/PerryNowPlaying.swift new file mode 100644 index 0000000000..eb42da14a6 --- /dev/null +++ b/crates/perry-ui-ios/swift/PerryNowPlaying.swift @@ -0,0 +1,239 @@ +import Foundation +import Observation +@_weakLinked import NowPlaying + +@_silgen_name("perry_ios_now_playing_command") +private func perryNowPlayingCommand(_ handle: Int64, _ command: Int32, _ value: Double) + +private func decodeNowPlayingUTF8(_ bytes: UnsafePointer?, _ length: Int32) -> String { + guard let bytes, length > 0 else { return "" } + return String(decoding: UnsafeBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) +} + +@available(iOS 27.0, *) +@Observable +@MainActor +private final class PerryNowPlayingModel: MediaSessionRepresentable { + let handle: Int64 + let id: String + var title: String + var artist: String + var album: String + var artworkURL: String + var stateCode: Int32 + var elapsedTime: TimeInterval + var duration: TimeInterval + var timestamp: Date + + init( + handle: Int64, + title: String, + artist: String, + album: String, + artworkURL: String, + stateCode: Int32, + elapsedTime: TimeInterval, + duration: TimeInterval + ) { + self.handle = handle + self.id = "perry-media-\(handle)" + self.title = title + self.artist = artist + self.album = album + self.artworkURL = artworkURL + self.stateCode = stateCode + self.elapsedTime = elapsedTime + self.duration = duration + self.timestamp = .now + } + + var content: (any MediaContentRepresentable)? { + let artwork: Artwork? = if artworkURL.isEmpty { + nil + } else { + Artwork(id: artworkURL) { [artworkURL] _ in + guard let url = URL(string: artworkURL) else { + throw URLError(.badURL) + } + return try ArtworkRepresentation(data: Data(contentsOf: url)) + } + } + return MusicContent( + id: "\(id)-\(title)-\(album)", + songTitle: title, + artistName: artist, + albumName: album, + type: .audio, + duration: duration > 0 ? .finite(duration) : nil, + artwork: artwork + ) + } + + var playbackSnapshot: MediaPlaybackSnapshot? { + MediaPlaybackSnapshot( + state: stateCode == 3 ? .playing(rate: 1.0) : .paused, + elapsedTime: elapsedTime, + timestamp: timestamp + ) + } + + var commands: [MediaCommand] { + [ + .play { perryNowPlayingCommand(self.handle, 1, 0) }, + .pause { perryNowPlayingCommand(self.handle, 2, 0) }, + .stop { perryNowPlayingCommand(self.handle, 3, 0) }, + .seekToPosition { value in + perryNowPlayingCommand(self.handle, 4, value) + }, + ] + } + + func updateMetadata(title: String, artist: String, album: String, artworkURL: String) { + self.title = title + self.artist = artist + self.album = album + self.artworkURL = artworkURL + } + + func updateSnapshot(stateCode: Int32, elapsedTime: TimeInterval, duration: TimeInterval) { + self.stateCode = stateCode + self.elapsedTime = elapsedTime + self.duration = duration + self.timestamp = .now + } +} + +@available(iOS 27.0, *) +@MainActor +private final class PerryNowPlayingSessions { + static let shared = PerryNowPlayingSessions() + + private struct Entry { + let model: PerryNowPlayingModel + let session: MediaSession + } + + private var entries: [Int64: Entry] = [:] + + func publish( + handle: Int64, + title: String, + artist: String, + album: String, + artworkURL: String, + stateCode: Int32, + elapsedTime: TimeInterval, + duration: TimeInterval + ) { + if let entry = entries[handle] { + entry.model.updateMetadata( + title: title, + artist: artist, + album: album, + artworkURL: artworkURL + ) + entry.model.updateSnapshot( + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + return + } + + let model = PerryNowPlayingModel( + handle: handle, + title: title, + artist: artist, + album: album, + artworkURL: artworkURL, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + let session = MediaSession(model) + entries[handle] = Entry(model: model, session: session) + Task { + try? await session.requestToBecomeSystemPrimary() + } + } + + func update(handle: Int64, stateCode: Int32, elapsedTime: TimeInterval, duration: TimeInterval) { + entries[handle]?.model.updateSnapshot( + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } + + func remove(handle: Int64) { + entries.removeValue(forKey: handle) + } +} + +@_cdecl("perry_swift_now_playing_is_available") +public func perrySwiftNowPlayingIsAvailable() -> Int32 { + if #available(iOS 27.0, *) { + return 1 + } + return 0 +} + +@_cdecl("perry_swift_now_playing_publish") +public func perrySwiftNowPlayingPublish( + _ handle: Int64, + _ titleBytes: UnsafePointer?, + _ titleLength: Int32, + _ artistBytes: UnsafePointer?, + _ artistLength: Int32, + _ albumBytes: UnsafePointer?, + _ albumLength: Int32, + _ artworkBytes: UnsafePointer?, + _ artworkLength: Int32, + _ stateCode: Int32, + _ elapsedTime: Double, + _ duration: Double +) { + guard #available(iOS 27.0, *) else { return } + let title = decodeNowPlayingUTF8(titleBytes, titleLength) + let artist = decodeNowPlayingUTF8(artistBytes, artistLength) + let album = decodeNowPlayingUTF8(albumBytes, albumLength) + let artworkURL = decodeNowPlayingUTF8(artworkBytes, artworkLength) + Task { @MainActor in + PerryNowPlayingSessions.shared.publish( + handle: handle, + title: title, + artist: artist, + album: album, + artworkURL: artworkURL, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } +} + +@_cdecl("perry_swift_now_playing_update") +public func perrySwiftNowPlayingUpdate( + _ handle: Int64, + _ stateCode: Int32, + _ elapsedTime: Double, + _ duration: Double +) { + guard #available(iOS 27.0, *) else { return } + Task { @MainActor in + PerryNowPlayingSessions.shared.update( + handle: handle, + stateCode: stateCode, + elapsedTime: elapsedTime, + duration: duration + ) + } +} + +@_cdecl("perry_swift_now_playing_remove") +public func perrySwiftNowPlayingRemove(_ handle: Int64) { + guard #available(iOS 27.0, *) else { return } + Task { @MainActor in + PerryNowPlayingSessions.shared.remove(handle: handle) + } +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 7dc62fcff2..2c6ed3d854 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -80,7 +80,7 @@ struct ModuleDiscovery { children: Vec, } -struct PreparedModule { +pub(crate) struct PreparedModule { canonical: PathBuf, module_name: String, hir_module: perry_hir::Module, @@ -1094,6 +1094,19 @@ fn collect_module_one( // program uses no widgets. if import.source == "perry/media" { ctx.needs_ui = true; + // On Xcode 27 the final linker uses this marker to compile + // Perry's Swift NowPlaying MediaSession adapter. Older SDKs + // keep using the existing MediaPlayer fallback. + ctx.native_module_imports.insert("perry/media".to_string()); + } + // iOS 27 adoption surface (#5536). The Rust ABI lives in + // libperry_ui_ios.a, while Foundation Models also needs a tiny + // Swift bridge compiled at final-link time. Keep a marker in the + // existing import set so the linker can opt in without forcing + // Swift/Xcode 26+ on unrelated iOS builds. + if import.source == "perry/ios" { + ctx.needs_ui = true; + ctx.native_module_imports.insert("perry/ios".to_string()); } // perry/system: most bindings (preferences, locale, device // info) live in stdlib, but the audio-recording, geolocation, @@ -1746,239 +1759,5 @@ fn collect_module_one( }) } -fn collect_module_finish( - prepared: PreparedModule, - ctx: &mut CompilationContext, - visited: &HashSet, - target: Option<&str>, - skip_transforms: bool, - progress: &VerboseProgress, -) -> Result<()> { - let PreparedModule { - canonical, - module_name, - mut hir_module, - } = prepared; - - // Issue #535 — `perry/ui` `state` desugar pass. - let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); - if !is_harmonyos { - perry_transform::state_desugar::run(&mut hir_module); - } - - // Run HIR transforms AFTER imports/re-exports have been recursively - // collected, so `ctx.native_modules` already contains every dependency - // of this module. The cross-module method-inlining harvester below - // pulls inlinable methods from those prior modules — without this - // ordering, a consumer (e.g. `sync-hotpath.test.ts`) would inline - // BEFORE `world.ts` finished processing, missing every `World.*` - // candidate and leaving the hot `world.set(...)` call as a runtime - // dispatch. - // - // Pre-existing constraint: `transform_async_to_generator` runs AFTER - // `inline_functions` (so inlined async bodies are still rewritten) - // and BEFORE `transform_generators` (which consumes the generator - // shape it produces). Issue #256. - if !skip_transforms { - progress.record(ProgressSnapshot { - stage: "transform", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - let mut extra_methods: std::collections::HashMap<(String, String), MethodCandidate> = - std::collections::HashMap::new(); - if std::env::var("PERRY_INLINE_DEBUG").is_ok() { - eprintln!( - "[INLINE-DRIVER] processing {}: prior modules={:?}", - hir_module.name, - ctx.native_modules - .values() - .map(|m| m.name.as_str()) - .collect::>() - ); - } - let enable_cross_module_inline = - ctx.native_modules.len() <= MAX_CROSS_MODULE_INLINE_PRIOR_MODULES; - if std::env::var("PERRY_INLINE_DEBUG").is_ok() && !enable_cross_module_inline { - eprintln!( - "[INLINE-DRIVER] skipping cross-module inline harvest for {}: prior_modules={} budget={}", - hir_module.name, - ctx.native_modules.len(), - MAX_CROSS_MODULE_INLINE_PRIOR_MODULES - ); - } - if enable_cross_module_inline { - for prior_module in ctx.native_modules.values() { - // The strict harvester rejects ExternFuncRef-using methods. - // The loose variant records each required extern name; - // `inline_functions` filters by destination imports. - // First-write-wins on key collision (rare — issue #309 cycle - // breaker). Strict-harvest entries are functionally equivalent - // when colliding with the loose variant (same body), so - // either ordering is correct. - for (k, v) in gather_cross_module_methods_with_extern_imports(prior_module) { - extra_methods.entry(k).or_insert(v); - } - for (k, v) in gather_cross_module_methods(prior_module) { - extra_methods.entry(k).or_insert(v); - } - } - } - // Cross-module field-type info: `(class_name, field_name) -> - // field_class_name`. Lets the inliner's `resolve_receiver_class` - // walk a chain like `world.commandBuffer.set(...)` — without it, - // the receiver match bails at the first PropertyGet and the call - // stays a runtime dispatch. Built from every prior module's - // class.fields where the type is `Named(...)`. - let mut extra_class_fields: std::collections::HashMap<(String, String), String> = - std::collections::HashMap::new(); - if enable_cross_module_inline { - for prior_module in ctx.native_modules.values() { - for class in &prior_module.classes { - for f in &class.fields { - if let perry_hir::types::Type::Named(field_class) = &f.ty { - extra_class_fields - .entry((class.name.clone(), f.name.clone())) - .or_insert_with(|| field_class.clone()); - } - } - } - } - } - // Cross-module anon-shape classes. Names are content-addressed - // (FNV-1a hash of the canonical shape key), so dedup-by-name across - // modules is correct: any two modules that synthesized a class for - // the same closed-shape literal end up with byte-identical class - // definitions under the same name. Required so that when - // `inline_functions` copies a method body referencing - // `__AnonShape_` into this module, codegen can resolve the - // class definition (otherwise the field list is missing and the - // literal lowers as a bare object with all properties dropped). - let mut extra_anon_classes: std::collections::HashMap = - std::collections::HashMap::new(); - if enable_cross_module_inline { - for prior_module in ctx.native_modules.values() { - for (k, v) in gather_cross_module_anon_classes(prior_module) { - extra_anon_classes.entry(k).or_insert(v); - } - } - } - // Interprocedural deforestation. Runs BEFORE inline_functions - // so the inliner sees deforested signatures (the rewritten - // function takes an accumulator param; inlined call sites then - // already use the new shape). Intra-module only — see - // `deforest::run` doc-comment for limitations and the manual - // ABC451D validation. - progress.record(ProgressSnapshot { - stage: "transform-deforest", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - perry_transform::deforest::run(&mut hir_module); - progress.record(ProgressSnapshot { - stage: "transform-inline-functions", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - inline_functions( - &mut hir_module, - &extra_methods, - &extra_class_fields, - &extra_anon_classes, - ); - // Post-inline HIR cleanups, in ONE call because they share their - // ordering constraint — `perry_transform::post_inline_cleanups`: - // static-trip-count for-loop unroll, then redundant property-read - // elimination over diverging guard chains. Both want the INLINED - // (and unrolled) bodies, and both must run BEFORE the async/generator - // transforms: those rewrite control flow into state-machine shapes the - // unroll match no longer recognizes, and box every body local into a - // shared mutable cell, which would turn a hoisted `const` into one - // more boxed cell. See crates/perry-transform/src/{unroll,prop_cse}. - progress.record(ProgressSnapshot { - stage: "transform-unroll-static-loops", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - perry_transform::post_inline_cleanups(&mut hir_module); - // Inline `finally` bodies before each abrupt completion - // (`return` / `break` / `continue` / labeled-break / labeled- - // continue) reachable inside a `try { ... } finally { Y }` - // shape. Must run BEFORE `transform_async_to_generator` because - // the async transform flattens `try`/`finally` into a flat - // state-machine sequence — an abrupt completion in the body - // terminates the state, leaving the appended finally as dead - // code. Issue #536. - progress.record(ProgressSnapshot { - stage: "transform-inline-finally", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - inline_finally_into_returns(&mut hir_module); - progress.record(ProgressSnapshot { - stage: "transform-async-to-generator", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - transform_async_to_generator(&mut hir_module); - // #8595: outline an oversized module-entry body into per-chunk - // functions so no single function carries the whole init (which is - // pathological for RS4GC relocation fan-out, ISel, and regalloc alike). - // Self-gating and fail-safe: a no-op unless PERRY_OUTLINE_ENTRY is set, - // and it declines (leaving the body unchanged) unless the whole body is - // provably safe to relocate. See perry-codegen `codegen::entry_outline`. - match perry_codegen::codegen::entry_outline::outline_entry_module(&mut hir_module) { - perry_codegen::codegen::entry_outline::OutlineOutcome::Outlined { chunks } => { - log::debug!( - "perry: outlined entry body of '{}' into {} chunk functions", - hir_module.name, - chunks - ); - } - perry_codegen::codegen::entry_outline::OutlineOutcome::Skipped(_) => {} - } - progress.record(ProgressSnapshot { - stage: "transform-generators", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); - transform_generators(&mut hir_module); - } - - // Set optional-feature gates (regex/temporal/url/crypto/events/etc.) so - // auto-optimize links only the runtime subsystems this module can reach. - feature_detect::detect_optional_feature_usage(ctx, &hir_module); - - let collected_after_insert = ctx.native_modules.len() + ctx.js_modules.len() + 1; - progress.record(ProgressSnapshot { - stage: "collected", - module_path: Some(&canonical), - module_name: Some(&module_name), - visited: Some(visited.len()), - collected: Some(collected_after_insert), - ..Default::default() - }); - ctx.native_modules.insert(canonical, hir_module); - Ok(()) -} +mod finish; +pub(crate) use finish::collect_module_finish; diff --git a/crates/perry/src/commands/compile/collect_modules/finish.rs b/crates/perry/src/commands/compile/collect_modules/finish.rs new file mode 100644 index 0000000000..b0673ff223 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/finish.rs @@ -0,0 +1,257 @@ +//! Module-collection finish step. +//! +//! Split out of `collect_modules.rs` (2000-line-per-file cap). Pure +//! relocation of `collect_module_finish`. + +use super::*; + +pub(crate) fn collect_module_finish( + prepared: PreparedModule, + ctx: &mut CompilationContext, + visited: &HashSet, + target: Option<&str>, + skip_transforms: bool, + progress: &VerboseProgress, +) -> Result<()> { + let PreparedModule { + canonical, + module_name, + mut hir_module, + } = prepared; + + // Issue #535 — `perry/ui` `state` desugar pass. + let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); + if !is_harmonyos { + perry_transform::state_desugar::run(&mut hir_module); + } + + // Run HIR transforms AFTER imports/re-exports have been recursively + // collected, so `ctx.native_modules` already contains every dependency + // of this module. The cross-module method-inlining harvester below + // pulls inlinable methods from those prior modules — without this + // ordering, a consumer (e.g. `sync-hotpath.test.ts`) would inline + // BEFORE `world.ts` finished processing, missing every `World.*` + // candidate and leaving the hot `world.set(...)` call as a runtime + // dispatch. + // + // Pre-existing constraint: `transform_async_to_generator` runs AFTER + // `inline_functions` (so inlined async bodies are still rewritten) + // and BEFORE `transform_generators` (which consumes the generator + // shape it produces). Issue #256. + if !skip_transforms { + progress.record(ProgressSnapshot { + stage: "transform", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + let mut extra_methods: std::collections::HashMap<(String, String), MethodCandidate> = + std::collections::HashMap::new(); + if std::env::var("PERRY_INLINE_DEBUG").is_ok() { + eprintln!( + "[INLINE-DRIVER] processing {}: prior modules={:?}", + hir_module.name, + ctx.native_modules + .values() + .map(|m| m.name.as_str()) + .collect::>() + ); + } + let enable_cross_module_inline = + ctx.native_modules.len() <= MAX_CROSS_MODULE_INLINE_PRIOR_MODULES; + if std::env::var("PERRY_INLINE_DEBUG").is_ok() && !enable_cross_module_inline { + eprintln!( + "[INLINE-DRIVER] skipping cross-module inline harvest for {}: prior_modules={} budget={}", + hir_module.name, + ctx.native_modules.len(), + MAX_CROSS_MODULE_INLINE_PRIOR_MODULES + ); + } + if enable_cross_module_inline { + for prior_module in ctx.native_modules.values() { + // The strict harvester rejects ExternFuncRef-using methods. + // The loose variant records each required extern name; + // `inline_functions` filters by destination imports. + // First-write-wins on key collision (rare — issue #309 cycle + // breaker). Strict-harvest entries are functionally equivalent + // when colliding with the loose variant (same body), so + // either ordering is correct. + for (k, v) in gather_cross_module_methods_with_extern_imports(prior_module) { + extra_methods.entry(k).or_insert(v); + } + for (k, v) in gather_cross_module_methods(prior_module) { + extra_methods.entry(k).or_insert(v); + } + } + } + // Cross-module field-type info: `(class_name, field_name) -> + // field_class_name`. Lets the inliner's `resolve_receiver_class` + // walk a chain like `world.commandBuffer.set(...)` — without it, + // the receiver match bails at the first PropertyGet and the call + // stays a runtime dispatch. Built from every prior module's + // class.fields where the type is `Named(...)`. + let mut extra_class_fields: std::collections::HashMap<(String, String), String> = + std::collections::HashMap::new(); + if enable_cross_module_inline { + for prior_module in ctx.native_modules.values() { + for class in &prior_module.classes { + for f in &class.fields { + if let perry_hir::types::Type::Named(field_class) = &f.ty { + extra_class_fields + .entry((class.name.clone(), f.name.clone())) + .or_insert_with(|| field_class.clone()); + } + } + } + } + } + // Cross-module anon-shape classes. Names are content-addressed + // (FNV-1a hash of the canonical shape key), so dedup-by-name across + // modules is correct: any two modules that synthesized a class for + // the same closed-shape literal end up with byte-identical class + // definitions under the same name. Required so that when + // `inline_functions` copies a method body referencing + // `__AnonShape_` into this module, codegen can resolve the + // class definition (otherwise the field list is missing and the + // literal lowers as a bare object with all properties dropped). + let mut extra_anon_classes: std::collections::HashMap = + std::collections::HashMap::new(); + if enable_cross_module_inline { + for prior_module in ctx.native_modules.values() { + for (k, v) in gather_cross_module_anon_classes(prior_module) { + extra_anon_classes.entry(k).or_insert(v); + } + } + } + // Interprocedural deforestation. Runs BEFORE inline_functions + // so the inliner sees deforested signatures (the rewritten + // function takes an accumulator param; inlined call sites then + // already use the new shape). Intra-module only — see + // `deforest::run` doc-comment for limitations and the manual + // ABC451D validation. + progress.record(ProgressSnapshot { + stage: "transform-deforest", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + perry_transform::deforest::run(&mut hir_module); + progress.record(ProgressSnapshot { + stage: "transform-inline-functions", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + inline_functions( + &mut hir_module, + &extra_methods, + &extra_class_fields, + &extra_anon_classes, + ); + // Post-inline HIR cleanups, in ONE call because they share their + // ordering constraint — `perry_transform::post_inline_cleanups`: + // static-trip-count for-loop unroll, then redundant property-read + // elimination over diverging guard chains. Both want the INLINED + // (and unrolled) bodies, and both must run BEFORE the async/generator + // transforms: those rewrite control flow into state-machine shapes the + // unroll match no longer recognizes, and box every body local into a + // shared mutable cell, which would turn a hoisted `const` into one + // more boxed cell. See crates/perry-transform/src/{unroll,prop_cse}. + progress.record(ProgressSnapshot { + stage: "transform-unroll-static-loops", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + perry_transform::post_inline_cleanups(&mut hir_module); + // Inline `finally` bodies before each abrupt completion + // (`return` / `break` / `continue` / labeled-break / labeled- + // continue) reachable inside a `try { ... } finally { Y }` + // shape. Must run BEFORE `transform_async_to_generator` because + // the async transform flattens `try`/`finally` into a flat + // state-machine sequence — an abrupt completion in the body + // terminates the state, leaving the appended finally as dead + // code. Issue #536. + progress.record(ProgressSnapshot { + stage: "transform-inline-finally", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + inline_finally_into_returns(&mut hir_module); + progress.record(ProgressSnapshot { + stage: "transform-async-to-generator", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + transform_async_to_generator(&mut hir_module); + // #8595: outline an oversized module-entry body into per-chunk + // functions so no single function carries the whole init (which is + // pathological for RS4GC relocation fan-out, ISel, and regalloc alike). + // Automatic only for very large entries; PERRY_OUTLINE_ENTRY=1 forces + // the transform and =0 disables it. Fail-safe exclusions leave the + // original body untouched. See perry-codegen `codegen::entry_outline`. + progress.record(ProgressSnapshot { + stage: "transform-outline-entry", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + match perry_codegen::codegen::entry_outline::outline_entry_module(&mut hir_module) { + perry_codegen::codegen::entry_outline::OutlineOutcome::Outlined { chunks } => { + log::debug!( + "perry: outlined entry body of '{}' into {} chunk functions", + hir_module.name, + chunks + ); + } + perry_codegen::codegen::entry_outline::OutlineOutcome::Skipped(reason) => { + log::debug!( + "perry: entry body of '{}' not outlined: {}", + hir_module.name, + reason + ); + } + } + progress.record(ProgressSnapshot { + stage: "transform-generators", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); + transform_generators(&mut hir_module); + } + + // Set optional-feature gates (regex/temporal/url/crypto/events/etc.) so + // auto-optimize links only the runtime subsystems this module can reach. + feature_detect::detect_optional_feature_usage(ctx, &hir_module); + + let collected_after_insert = ctx.native_modules.len() + ctx.js_modules.len() + 1; + progress.record(ProgressSnapshot { + stage: "collected", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(collected_after_insert), + ..Default::default() + }); + ctx.native_modules.insert(canonical, hir_module); + Ok(()) +} diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index c43dd22b99..b7384fdfa4 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -679,49 +679,7 @@ pub(crate) fn build_and_run_link( } if is_watchos { - // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) - let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); - let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); - if !is_watchos_game_loop { - cmd.arg("-framework").arg("SwiftUI"); - } - cmd.arg("-framework") - .arg("WatchKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend/Schedule/OnTap) - // AVFAudio: AVAudioEngine / AVAudioSession / AVAudioApplication for - // microphone capture + the record-permission API (perry/system - // audioStart, getLevel, recording). Without this the audio classes - // aren't registered in the objc runtime, so `AnyClass::get` returns - // nil and audio silently no-ops on device — e.g. a watchOS dB meter - // shows no levels and never prompts for mic permission. (The iOS - // branch already links these; watchOS was missing them.) - .arg("-framework") - .arg("AVFAudio") - .arg("-framework") - .arg("AVFoundation") - .arg("-lSystem") - .arg("-lresolv"); - if is_watchos_game_loop { - // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT - // in the watchOS SDK — the native lib must dlopen it or supply its own - // path to the device's Metal dylib). -lobjc for the dynamic - // WKApplicationDelegate class registered from watchos_game_loop.rs. - cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); - } - if is_watchos_swift_app { - // SceneKit for SceneView-backed 3D rendering from the native lib's - // `@main struct App: App`. The lib may additionally use Canvas (2D, - // already covered by SwiftUI) or SpriteKit (opt-in via the - // manifest's `frameworks` list). - cmd.arg("-framework").arg("SceneKit"); - } + watchos_frameworks::append_watchos_frameworks(&mut cmd, compiled_features); } else if is_ios { // iOS frameworks cmd.arg("-framework") @@ -766,6 +724,12 @@ pub(crate) fn build_and_run_link( .arg("-lresolv") .arg("-lobjc") .arg("-lSystem"); + if ctx.native_module_imports.contains("perry/ios") { + // FoundationModels is Swift-only. The bridge object is compiled + // in platform_cmd; weak-link so the app's iOS 17 deployment + // target still launches and reports `unsupported` before iOS 26. + cmd.arg("-weak_framework").arg("FoundationModels"); + } } else if is_visionos { cmd.arg("-framework") .arg("SwiftUI") diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 4f08dde359..1fd3c699ec 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -51,6 +51,7 @@ mod linux_ui_libs; mod native_features; mod pkg_config; mod platform_cmd; +mod watchos_frameworks; mod windows_link; use archive_cache::{prepare_well_known_archives, PreparedArchiveInputs}; diff --git a/crates/perry/src/commands/compile/link/platform_cmd.rs b/crates/perry/src/commands/compile/link/platform_cmd.rs index 19f0470ad8..802e6d8b9c 100644 --- a/crates/perry/src/commands/compile/link/platform_cmd.rs +++ b/crates/perry/src/commands/compile/link/platform_cmd.rs @@ -8,6 +8,106 @@ //! platform needs before any of the per-link-line code runs. use super::*; +use sha2::{Digest, Sha256}; + +const FOUNDATION_MODELS_SWIFT: &str = + include_str!("../../../../../perry-ui-ios/swift/PerryFoundationModels.swift"); +const NOW_PLAYING_SWIFT: &str = + include_str!("../../../../../perry-ui-ios/swift/PerryNowPlaying.swift"); + +fn needs_foundation_models_bridge(ctx: &CompilationContext) -> bool { + ctx.native_module_imports.contains("perry/ios") +} + +fn framework_exists(sysroot: &str, name: &str) -> bool { + Path::new(sysroot) + .join("System/Library/Frameworks") + .join(format!("{name}.framework")) + .exists() +} + +fn compile_swift_bridge( + ctx: &CompilationContext, + sdk: &str, + sysroot: &str, + triple: &str, + source_contents: &str, + source_stem: &str, + module_name: &str, +) -> Result { + let swiftc = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", sdk, "--find", "swiftc"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + if swiftc.is_empty() { + return Err(anyhow!("swiftc was not found for the {sdk} SDK")); + } + + let mut hasher = Sha256::new(); + hasher.update(source_contents.as_bytes()); + hasher.update(sysroot.as_bytes()); + hasher.update(triple.as_bytes()); + let digest = hex::encode(hasher.finalize()); + let bridge_dir = ctx.cache_dir.join("swift-bridges"); + fs::create_dir_all(&bridge_dir)?; + let source = bridge_dir.join(format!("{source_stem}-{}.swift", &digest[..16])); + let object = bridge_dir.join(format!("{source_stem}-{}.o", &digest[..16])); + + if !object.exists() { + fs::write(&source, source_contents)?; + let output = Command::new(&swiftc) + .arg("-parse-as-library") + .arg("-emit-object") + .arg("-O") + .arg("-module-name") + .arg(module_name) + .arg("-target") + .arg(triple) + .arg("-sdk") + .arg(sysroot) + .arg(&source) + .arg("-o") + .arg(&object) + .output()?; + if !output.status.success() { + return Err(anyhow!( + "swiftc failed compiling Perry's {source_stem} bridge:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + } + Ok(object) +} + +/// Compile the Swift-only Foundation Models adapter to a content-addressed +/// object. Unrelated iOS builds stay on the existing clang-only path and keep +/// working with older Xcode installations. +fn compile_foundation_models_bridge( + ctx: &CompilationContext, + sdk: &str, + sysroot: &str, + triple: &str, +) -> Result { + if !framework_exists(sysroot, "FoundationModels") { + return Err(anyhow!( + "perry/ios Foundation Models support requires an Xcode SDK that contains FoundationModels.framework (Xcode 26 or later)" + )); + } + compile_swift_bridge( + ctx, + sdk, + sysroot, + triple, + FOUNDATION_MODELS_SWIFT, + "PerryFoundationModels", + "PerryFoundationModelsBridge", + ) +} /// Construct the platform-specific linker `Command` and prime it with the /// toolchain/sysroot/triple flags that every per-platform branch needs @@ -35,8 +135,12 @@ pub fn select_linker_command( is_tvos: bool, is_cross_tvos: bool, ) -> Result { - let _ = ctx; // reserved for future per-platform context-driven flags - // For cross-compilation targets, use the appropriate toolchain + if is_cross_ios && needs_foundation_models_bridge(ctx) { + return Err(anyhow!( + "perry/ios requires Apple's Swift compiler and Foundation Models SDK; build this target on macOS with Xcode 26 or later" + )); + } + // For cross-compilation targets, use the appropriate toolchain let cmd = if is_watchos { let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); @@ -474,6 +578,37 @@ pub fn select_linker_command( // explicitly. Mirrors the cross-iOS branch. .arg("-lc++") .arg("-lc++abi"); + if needs_foundation_models_bridge(ctx) { + c.arg(compile_foundation_models_bridge( + ctx, sdk, &sysroot, triple, + )?); + } + if ctx.native_module_imports.contains("perry/media") + && framework_exists(&sysroot, "NowPlaying") + { + c.arg(compile_swift_bridge( + ctx, + sdk, + &sysroot, + triple, + NOW_PLAYING_SWIFT, + "PerryNowPlaying", + "PerryNowPlayingBridge", + )?) + .arg("-weak_framework") + .arg("NowPlaying"); + // Rust locates these optional bridge entry points with `dlsym` + // so old-SDK binaries retain the MediaPlayer fallback. Preserve + // the string-referenced exports when the final link dead-strips. + for symbol in [ + "perry_swift_now_playing_is_available", + "perry_swift_now_playing_publish", + "perry_swift_now_playing_update", + "perry_swift_now_playing_remove", + ] { + c.arg(format!("-Wl,-u,_{symbol}")); + } + } c } else if is_tvos && is_cross_tvos { // Cross-compile tvOS from Linux using ld64.lld + Apple SDK sysroot. diff --git a/crates/perry/src/commands/compile/link/watchos_frameworks.rs b/crates/perry/src/commands/compile/link/watchos_frameworks.rs new file mode 100644 index 0000000000..a927e9f4c1 --- /dev/null +++ b/crates/perry/src/commands/compile/link/watchos_frameworks.rs @@ -0,0 +1,54 @@ +//! watchOS framework link flags. +//! +//! Split out of `build_and_run.rs` (2000-line-per-file cap). Pure +//! relocation of the `if is_watchos { ... }` arm body. + +/// Append the watchOS framework arguments for this build. +pub(super) fn append_watchos_frameworks( + cmd: &mut std::process::Command, + compiled_features: &[String], +) { + // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) + let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); + let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); + if !is_watchos_game_loop { + cmd.arg("-framework").arg("SwiftUI"); + } + cmd.arg("-framework") + .arg("WatchKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend/Schedule/OnTap) + // AVFAudio: AVAudioEngine / AVAudioSession / AVAudioApplication for + // microphone capture + the record-permission API (perry/system + // audioStart, getLevel, recording). Without this the audio classes + // aren't registered in the objc runtime, so `AnyClass::get` returns + // nil and audio silently no-ops on device — e.g. a watchOS dB meter + // shows no levels and never prompts for mic permission. (The iOS + // branch already links these; watchOS was missing them.) + .arg("-framework") + .arg("AVFAudio") + .arg("-framework") + .arg("AVFoundation") + .arg("-lSystem") + .arg("-lresolv"); + if is_watchos_game_loop { + // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT + // in the watchOS SDK — the native lib must dlopen it or supply its own + // path to the device's Metal dylib). -lobjc for the dynamic + // WKApplicationDelegate class registered from watchos_game_loop.rs. + cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); + } + if is_watchos_swift_app { + // SceneKit for SceneView-backed 3D rendering from the native lib's + // `@main struct App: App`. The lib may additionally use Canvas (2D, + // already covered by SwiftUI) or SpriteKit (opt-in via the + // manifest's `frameworks` list). + cmd.arg("-framework").arg("SceneKit"); + } +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 63bf6cb8e6..38eb33345c 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1196,7 +1196,7 @@ pub fn run_with_parse_cache( // These are in exported_objects but not in functions, so they need param counts too let exported_set: std::collections::HashSet<&String> = hir_module.exported_objects.iter().collect(); - for stmt in &hir_module.init { + for stmt in perry_codegen::codegen::entry_outline::logical_entry_stmts(hir_module) { if let perry_hir::ir::Stmt::Let { name, init: Some(expr), diff --git a/crates/perry/src/commands/types.rs b/crates/perry/src/commands/types.rs index 1e73a79090..6f93cb2071 100644 --- a/crates/perry/src/commands/types.rs +++ b/crates/perry/src/commands/types.rs @@ -20,6 +20,7 @@ pub struct TypesArgs { // Canonical `.d.ts` sources, embedded at compile time from `types/perry/`. const PERRY_UI_DTS: &str = include_str!("../../../../types/perry/ui/index.d.ts"); +const PERRY_IOS_DTS: &str = include_str!("../../../../types/perry/ios/index.d.ts"); const PERRY_THREAD_DTS: &str = include_str!("../../../../types/perry/thread/index.d.ts"); const PERRY_GC_DTS: &str = include_str!("../../../../types/perry/gc/index.d.ts"); const PERRY_I18N_DTS: &str = include_str!("../../../../types/perry/i18n/index.d.ts"); @@ -46,6 +47,7 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { let modules: &[(&str, &str)] = &[ ("ui", PERRY_UI_DTS), + ("ios", PERRY_IOS_DTS), ("thread", PERRY_THREAD_DTS), ("gc", PERRY_GC_DTS), ("i18n", PERRY_I18N_DTS), @@ -84,7 +86,7 @@ pub fn write_perry_type_stubs(project_path: &Path, quiet: bool) -> Result<()> { if !quiet { println!( - " Created .perry/types/ type stubs (ui, thread, i18n, system, media, audio, tui, webassembly, build, native, stdlib)" + " Created .perry/types/ type stubs (ui, ios, thread, i18n, system, media, audio, tui, webassembly, build, native, stdlib)" ); } @@ -140,4 +142,16 @@ mod tests { assert!(source.contains("export type pod")); assert!(source.contains("export declare const NativeArena")); } + + #[test] + fn writes_perry_ios_type_stub() { + let project = tempfile::tempdir().expect("temporary project"); + write_perry_type_stubs(project.path(), true).expect("write type stubs"); + + let ios_stub = project.path().join(".perry/types/perry/ios/index.d.ts"); + let source = fs::read_to_string(ios_stub).expect("read iOS type stub"); + assert!(source.contains("export interface LayoutEnvironment")); + assert!(source.contains("foundationModelAvailability")); + assert!(source.contains("Promise")); + } } diff --git a/crates/perry/tests/entry_outline_transform_8595.rs b/crates/perry/tests/entry_outline_transform_8595.rs index 39b342174c..7b60f51839 100644 --- a/crates/perry/tests/entry_outline_transform_8595.rs +++ b/crates/perry/tests/entry_outline_transform_8595.rs @@ -1,14 +1,13 @@ //! #8595 entry-outlining transform — end-to-end differential. //! -//! `PERRY_OUTLINE_ENTRY` rewrites an eligible module-entry body into per-chunk -//! functions, hoisting top-level `let` declarations so cross-chunk state is -//! globalized (via the existing `emit_module_globals` escape rule) and shared -//! across the chunks. This must not change observable behavior — including -//! under a relocating minor, since the cross-chunk objects now live in module -//! globals that a moving collection has to find and rewrite. +//! Oversized entries are outlined automatically; `PERRY_OUTLINE_ENTRY=1` +//! forces the transform on small differential fixtures. Original declarations +//! move unchanged into chunk functions, while module-global discovery gives +//! them shared rooted storage. This must not change observable behavior — +//! including under a relocating minor. //! //! The same program is compiled twice from identical source: -//! * `PERRY_OUTLINE_ENTRY` unset — the ordinary single-function entry; +//! * `PERRY_OUTLINE_ENTRY=0` — the ordinary single-function entry; //! * `PERRY_OUTLINE_ENTRY=1 PERRY_OUTLINE_ENTRY_CHUNK_STMTS=1` — maximum //! chunking, so every top-level statement is its own chunk function and the //! object lets `a`/`b`/`c` are genuinely defined in one chunk and read in @@ -24,14 +23,14 @@ fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } -/// Straight-line, no exports / no control flow / no top-level await, so it is -/// an eligible outlining candidate. `a`/`b`/`c` are heap objects defined in -/// separate chunks and read together in a later chunk. +/// Exported immutable bindings exercise the module-global/export scans that +/// used to gate outlining out entirely. `a`/`b`/`c` are heap objects defined +/// in separate chunks and read together in a later chunk. const SOURCE: &str = r#" let a = { v: 3 }; let b = { v: 4 }; let c = { v: 5 }; -let sum = a.v + b.v + c.v; +export const sum = a.v + b.v + c.v; console.log("sum:" + sum); "#; @@ -52,6 +51,7 @@ const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_OUTLINE_ENTRY", "PERRY_OUTLINE_ENTRY_CHUNK_STMTS", "PERRY_OUTLINE_ENTRY_REPORT", + "PERRY_OUTLINE_SCAN_8595", ]; fn compile(dir: &std::path::Path, name: &str, source: &str, outline: bool) -> (PathBuf, String) { @@ -72,6 +72,8 @@ fn compile(dir: &std::path::Path, name: &str, source: &str, outline: bool) -> (P cmd.env("PERRY_OUTLINE_ENTRY", "1") .env("PERRY_OUTLINE_ENTRY_CHUNK_STMTS", "1") .env("RUST_LOG", "debug"); + } else { + cmd.env("PERRY_OUTLINE_ENTRY", "0"); } let out = cmd.output().expect("run perry compile"); assert!( @@ -115,6 +117,11 @@ fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str, expect expected, "[{arm_label}] wrong output" ); + assert!( + run.stderr.is_empty(), + "[{arm_label}] unexpected stderr:\n{}", + String::from_utf8_lossy(&run.stderr) + ); } } @@ -139,29 +146,101 @@ fn outlined_entry_matches_the_single_function_entry_under_a_relocating_minor() { run_arms(&on_bin, dir.path(), "outlined", EXPECTED); } -/// A body with a top-level `if` between relocatable runs: the transform must -/// outline the runs and keep the `if` inline, in order — and the result must -/// still match the single-function build under a relocating minor. +/// Script-global function reflection and a `globalThis` read used to be a +/// conservative coupling bail. Structured control flow now moves as one chunk +/// statement, while the reflection still happens before user code. const INTERLEAVE_SOURCE: &str = r#" +function reflected() { return 7; } let a = { v: 10 }; let b = { v: 20 }; if (a.v < b.v) { console.log("less"); } let c = { v: 30 }; -console.log("total:" + (a.v + b.v + c.v)); +console.log("total:" + (a.v + b.v + c.v + globalThis.reflected())); "#; -const INTERLEAVE_EXPECTED: &str = "less\ntotal:60\n"; +const INTERLEAVE_EXPECTED: &str = "less\ntotal:67\n"; #[test] -fn outlining_interleaves_chunks_around_inline_control_flow() { +fn outlining_preserves_structured_control_flow_and_script_global_reflection() { let dir = tempfile::tempdir().expect("tempdir"); let (off_bin, _) = compile(dir.path(), "int_off", INTERLEAVE_SOURCE, false); let (on_bin, on_stderr) = compile(dir.path(), "int_on", INTERLEAVE_SOURCE, true); assert!( on_stderr.contains("outlined entry body of 'int_on.ts' into") && on_stderr.contains("chunk functions"), - "the interleaved body must still outline:\nstderr:\n{on_stderr}" + "the structured body must still outline:\nstderr:\n{on_stderr}" ); run_arms(&off_bin, dir.path(), "single-function", INTERLEAVE_EXPECTED); run_arms(&on_bin, dir.path(), "outlined", INTERLEAVE_EXPECTED); } + +/// `process.env` literals in the entry are applied before static dependencies +/// initialize. The early scan must follow chunk calls after outlining. +#[test] +fn outlining_keeps_early_process_env_assignment_visible_to_dependencies() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("dep.ts"), + r#"export const observed = process.env.PERRY_OUTLINE_SCAN_8595 || "missing";"#, + ) + .expect("write dependency"); + let source = r#" +process.env.PERRY_OUTLINE_SCAN_8595 = "visible"; +import { observed } from "./dep"; +console.log(observed); +"#; + let (off_bin, _) = compile(dir.path(), "env_off", source, false); + let (on_bin, on_stderr) = compile(dir.path(), "env_on", source, true); + assert!( + on_stderr.contains("outlined entry body of 'env_on.ts' into"), + "the env fixture must outline:\nstderr:\n{on_stderr}" + ); + run_arms(&off_bin, dir.path(), "single-function", "visible\n"); + run_arms(&on_bin, dir.path(), "outlined", "visible\n"); +} + +#[test] +fn oversized_entry_outlines_automatically_without_an_environment_opt_in() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut source = String::new(); + for id in 0..1_001 { + source.push_str(&format!("const v{id} = {id};\n")); + } + source.push_str("console.log(v0 + v1000);\n"); + + let entry = dir.path().join("auto.ts"); + let output = dir.path().join("auto"); + std::fs::write(&entry, source).expect("write automatic outlining fixture"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("RUST_LOG", "debug"); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + let compiled = cmd.output().expect("compile automatic outlining fixture"); + assert!( + compiled.status.success(), + "automatic outlining compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compiled.stdout), + String::from_utf8_lossy(&compiled.stderr) + ); + let stderr = String::from_utf8_lossy(&compiled.stderr); + assert!( + stderr.contains("outlined entry body of 'auto.ts' into 6 chunk functions"), + "the default 1,000-statement gate should emit six bounded chunks:\n{stderr}" + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .env_remove("PERRY_OUTLINE_SCAN_8595") + .output() + .expect("run automatic outlining fixture"); + assert!(run.status.success(), "automatic outlined binary failed"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "1000\n"); + assert!(run.stderr.is_empty()); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 977a98312c..944fb58009 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2051 entries across 133 modules +// Coverage: 2059 entries across 134 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2711,6 +2711,23 @@ declare module "perry/i18n" { export function t(...args: any[]): any; } +declare module "perry/ios" { + /** stdlib */ + export function createLanguageModelSession(...args: any[]): any; + /** stdlib */ + export function destroyLanguageModelSession(...args: any[]): any; + /** stdlib */ + export function foundationModelAvailability(...args: any[]): any; + /** stdlib */ + export function getLayoutEnvironment(...args: any[]): any; + /** stdlib */ + export function offLayoutChange(...args: any[]): any; + /** stdlib */ + export function onLayoutChange(...args: any[]): any; + /** stdlib */ + export function respond(...args: any[]): any; +} + declare module "perry/media" { /** stdlib */ export function createPlayer(...args: any[]): any; @@ -4019,6 +4036,8 @@ declare module "tls" { /** stdlib */ export function getCACertificates(type: any): any; /** stdlib */ + export function getCertificateCompressionAlgorithms(...args: any[]): any; + /** stdlib */ export function getCiphers(...args: any[]): any; /** stdlib */ export function setDefaultCACertificates(certs: any): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 579ca5f971..be1ff6ecc8 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2994 entries across 135 modules. +Total: 3009 entries across 136 modules. ## Modules @@ -91,6 +91,7 @@ Total: 2994 entries across 135 modules. - [`perry/container-compose`](#perrycontainer-compose) - [`perry/gc`](#perrygc) - [`perry/i18n`](#perryi18n) +- [`perry/ios`](#perryios) - [`perry/media`](#perrymedia) - [`perry/native`](#perrynative) - [`perry/plugin`](#perryplugin) @@ -2221,10 +2222,16 @@ Total: 2994 entries across 135 modules. - `getConnections` — instance *(class: `Server`)* - `getDefaultAutoSelectFamily` — module - `getDefaultAutoSelectFamilyAttemptTimeout` — module +- `getEphemeralKeyInfo` — instance *(class: `Socket`)* +- `getFinished` — instance *(class: `Socket`)* - `getPeerCertificate` — instance *(class: `Socket`)* +- `getPeerFinished` — instance *(class: `Socket`)* +- `getPeerX509Certificate` — instance *(class: `Socket`)* - `getProtocol` — instance *(class: `Socket`)* - `getSession` — instance *(class: `Socket`)* +- `getSharedSigalgs` — instance *(class: `Socket`)* - `getTypeOfService` — instance *(class: `Socket`)* +- `getX509Certificate` — instance *(class: `Socket`)* - `isBlockList` — module *(class: `BlockList`)* - `isIP` — module - `isIPv4` — module @@ -2268,6 +2275,7 @@ Total: 2994 entries across 135 modules. - `setDefaultEncoding` — instance *(class: `Socket`)* - `setEncoding` — instance *(class: `Socket`)* - `setKeepAlive` — instance *(class: `Socket`)* +- `setKeyCert` — instance *(class: `Socket`)* - `setMaxSendFragment` — instance *(class: `Socket`)* - `setNoDelay` — instance *(class: `Socket`)* - `setTimeout` — instance *(class: `Socket`)* @@ -2610,6 +2618,18 @@ Total: 2994 entries across 135 modules. - `ShortDate` — module - `t` — module +## `perry/ios` + +### Methods + +- `createLanguageModelSession` — module +- `destroyLanguageModelSession` — module +- `foundationModelAvailability` — module +- `getLayoutEnvironment` — module +- `offLayoutChange` — module +- `onLayoutChange` — module +- `respond` — module + ## `perry/media` ### Methods @@ -3639,6 +3659,7 @@ Total: 2994 entries across 135 modules. - `createServer` — module - `eventNames` — instance *(class: `Server`)* - `getCACertificates` — module +- `getCertificateCompressionAlgorithms` — module - `getCiphers` — module - `getTicketKeys` — instance *(class: `Server`)* - `listen` — instance *(class: `Server`)* diff --git a/docs/src/platforms/ios.md b/docs/src/platforms/ios.md index 203805474e..87cb7672a7 100644 --- a/docs/src/platforms/ios.md +++ b/docs/src/platforms/ios.md @@ -68,7 +68,77 @@ iOS apps use `UIApplicationMain` with a deferred creation pattern: {{#include ../../examples/platforms/ui/ios_app.ts:ios-app}} ``` -The `App()` call triggers `UIApplicationMain`, and your render function is called via `PerryAppDelegate` once the app is ready. +The `App()` call triggers `UIApplicationMain`, and your render function is called via `PerryAppDelegate` once the app is ready. Perry-generated apps use `UIWindowScene`, `PerrySceneDelegate`, and an `UIApplicationSceneManifest`, which also satisfies the scene-based lifecycle required for apps built with the iOS 27 SDK. + +## Adaptive layouts + +Use `perry/ios` to inspect the active scene rather than branching on a device model or physical screen size: + +```typescript,no-test +import { + getLayoutEnvironment, + onLayoutChange, + offLayoutChange, +} from "perry/ios"; + +const initial = getLayoutEnvironment(); +console.log(initial.width, initial.horizontalSizeClass, initial.windowMode); + +const subscription = onLayoutChange((layout) => { + if (layout.horizontalSizeClass === "compact") { + // Present a compact navigation treatment. + } + if (layout.isFourByThree || layout.windowMode === "sideBySide") { + // Reflow content for 4:3 or iPad side-by-side multitasking. + } + + // These insets describe display cutouts, rounded corners, and any future + // interrupted-display geometry exposed to the scene by UIKit. + console.log(layout.safeAreaTop, layout.safeAreaRight); +}); + +// When the observer is no longer needed: +offLayoutChange(subscription); +``` + +Snapshots contain the window dimensions and aspect ratio, display scale, horizontal and vertical size classes, orientation, window mode, multitasking and 4:3 flags, and all four safe-area insets. On iOS 27 they also contain the effective scene's system-space frame, interactive-resize state, and orientation-lock state. The callback fires once when a scene is available and then after meaningful bounds, trait, safe-area, or effective-geometry changes. + +UIKit does not expose a separate public hardware-model or hinge-state property. Safe areas, effective scene geometry, and trait collections are the supported adaptive signals, and they continue to work when one device moves among full-screen, side-by-side, and freeform window modes. Perry's `SplitView` and `FrameSplit` also cap their preferred sidebar at 45% of the current scene width so the detail pane remains usable in narrow layouts. + +## Foundation Models + +The simple, unstructured Foundation Models flow is available through `perry/ios`: + +```typescript,no-test +import { + foundationModelAvailability, + createLanguageModelSession, + respond, + destroyLanguageModelSession, +} from "perry/ios"; + +if (foundationModelAvailability() === "available") { + const session = createLanguageModelSession( + "Answer in one short, factual sentence.", + ); + try { + const answer = await respond(session, "Why is the sky blue?"); + console.log(answer); + } finally { + destroyLanguageModelSession(session); + } +} +``` + +The bridge uses Apple's default `LanguageModelSession`, preserves conversational context while a session handle is reused, and rejects the returned promise when generation fails. Check availability first: unsupported OS versions and unavailable Apple Intelligence configurations are reported without loading the framework. This surface intentionally returns plain strings; structured `@Generable` responses are outside the current API. + +Building a source file that imports `perry/ios` requires an Xcode SDK containing `FoundationModels.framework` (Xcode 26 or later). The framework is weak-linked, so the normal iOS 17 deployment target remains valid. + +## Now Playing on iOS 27 + +`perry/media.setNowPlaying(...)` is the public Perry API for Lock Screen, Control Center, Dynamic Island, CarPlay, artwork, playback progress, and play/pause/stop/seek commands. When an Xcode 27 SDK containing `NowPlaying.framework` is installed, Perry automatically compiles an observable `MediaSession` bridge and publishes each player through the new framework. Builds made with older SDKs, and devices before iOS 27, retain the existing `MPNowPlayingInfoCenter` / `MPRemoteCommandCenter` compatibility path. Perry never activates both paths for one local session. + +The iOS 27 SDK is beta software until Apple's GM release. This support does not change Perry's SDK build markers or version; distribution metadata should only be updated once the GM toolchain can submit to App Store Connect. ## iOS Widgets (WidgetKit) diff --git a/docs/src/system/media.md b/docs/src/system/media.md index fc8a1f5d2b..0e57d384d6 100644 --- a/docs/src/system/media.md +++ b/docs/src/system/media.md @@ -84,7 +84,7 @@ tick if the signal hasn't arrived. | Platform | Backend | Status | | --- | --- | --- | | macOS | AVPlayer + MPNowPlayingInfoCenter + MPRemoteCommandCenter | **Implemented** + lock-screen | -| iOS | AVPlayer + AVAudioSession Playback + UIImage artwork | **Implemented** + lock-screen | +| iOS | AVPlayer + NowPlaying MediaSession (iOS 27) or MediaPlayer fallback | **Implemented** + lock-screen | | tvOS | AVPlayer + Siri Remote play/pause/skip | **Implemented** + remote | | visionOS | AVPlayer + UIImage artwork | **Implemented** + lock-screen | | Android | `android.media.MediaPlayer` + `MediaSessionCompat` via JNI | **Implemented** + lock-screen | @@ -176,20 +176,28 @@ calling code. Implementation detail varies: ## Now Playing on Apple platforms -Apple's MPNowPlayingInfoCenter is a process-wide singleton — the most -recent `setNowPlaying` call wins. For a single-player app (Subsonic -client, podcast player) this matches user expectation. The -MPRemoteCommandCenter handlers route `play` / `pause` / `togglePlayPause` -events to the **first live player handle** — multi-player apps that -need an explicit "active player" should manage that themselves. +On iOS 27, an app built with an Xcode 27 SDK uses Apple's new NowPlaying +framework. Perry creates an observable `MediaSession` per player, keeps its +metadata, playback snapshot, elapsed time, and duration synchronized, and +routes play, pause, stop, and seek commands to the matching `AVPlayer` handle. +The bridge requests system-primary status so the session appears on the Lock +Screen, in Control Center and Dynamic Island, and on connected surfaces such +as CarPlay. + +On devices before iOS 27, builds made without the new framework, and other +Apple platforms, Perry uses `MPNowPlayingInfoCenter` and +`MPRemoteCommandCenter`. `MPNowPlayingInfoCenter` is process-wide, so the most +recent `setNowPlaying` call wins on that compatibility path. The remote command +handlers route events to the first live player handle. Perry selects exactly +one implementation for a local iOS session; Apple warns that mixing the new +NowPlaying and legacy MediaPlayer APIs has undefined behavior. `artworkUrl` accepts: -- `file://` paths — loaded synchronously via NSImage / UIImage -- `https://` URLs — fetched synchronously via NSData(contentsOf:) and - wrapped in UIImage. The synchronous fetch is acceptable for a one-off - artwork load (the MPNowPlayingInfoCenter dict is consumed - synchronously when set). +- `file://` paths — loaded via the platform image/artwork loader +- `https://` URLs — requested when the system needs artwork. The legacy + MediaPlayer path fetches once via `NSData(contentsOf:)`; iOS 27's + NowPlaying `Artwork` provider loads it asynchronously on demand. ### watchOS Info.plist requirements diff --git a/docs/statepoint-gc-experiment.md b/docs/statepoint-gc-experiment.md index b91f39225f..43cb2d6e8e 100644 --- a/docs/statepoint-gc-experiment.md +++ b/docs/statepoint-gc-experiment.md @@ -1053,3 +1053,30 @@ Closing the last axis therefore needs the root set to shrink, not the encoding: 221 KB of map for 154k roots is already near this format's floor. That is the repsel-promotion lever the earlier projection named, and it is still the outstanding work. + +## Safepoint density and the caller-frame constraint (2026-08-24) + +A polling design cannot soundly mark every ordinary call as +`gc-leaf-function` under LLVM statepoints. If `A` calls `B`, and `B` reaches an +allocation or loop poll that starts moving collection, `A` is suspended at its +call to `B`. The collector must find and rewrite `A`'s live managed values at +that return PC. Omitting the statepoint on `A -> B` would remove exactly that +caller-frame relocation map; putting a poll only inside `B` does not recreate +it. VM poll points reduce where collection may begin, but every active caller +edge beneath such a poll still needs an oop/relocation map. + +Perry therefore applies the maximal local reduction that preserves this +constraint: compute a whole-module, greatest-fixed-point GC-effect closure and +mark a direct generated call leaf only when its callee cannot transitively +reach collection. The proof admits mutually recursive pure components. It +fails closed on any allocation or poll helper, indirect call, unknown external, +or cross-module call, and propagates that result back through callers. Runtime +helpers remain governed by the audited `GcCallEffect` table. + +The closure is computed before codegen-unit partitioning and carried into every +unit, so a safe direct edge remains leaf even when caller and callee are emitted +into different objects. Textual and native LLVM construction consume the same +set; the native dialect also preserves the marker on `invoke` edges inside +`try`. Calls outside the proven set remain ordinary RS4GC safepoints. Reducing +those further requires a different frame representation (for example, spilling +caller roots to a shadow frame), not merely moving the collection trigger. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 01726315c7..b143e80c50 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1490,6 +1490,14 @@ "file": "crates/perry-ui-gtk4/src/widgets/webview.rs", "name": "WEBVIEW_STATES" }, + { + "file": "crates/perry-ui-ios/src/adaptive_layout.rs", + "name": "LAST_SNAPSHOT" + }, + { + "file": "crates/perry-ui-ios/src/adaptive_layout.rs", + "name": "LISTENERS" + }, { "file": "crates/perry-ui-ios/src/app.rs", "name": "PENDING_CONFIG" diff --git a/types/perry/ios/index.d.ts b/types/perry/ios/index.d.ts new file mode 100644 index 0000000000..ea92587b8f --- /dev/null +++ b/types/perry/ios/index.d.ts @@ -0,0 +1,91 @@ +// Type declarations for iOS-specific Perry APIs. + +/** UIKit size class for the active scene. */ +export type LayoutSizeClass = "compact" | "regular" | "unspecified"; + +/** How the active scene currently occupies its display. */ +export type WindowMode = "fullScreen" | "sideBySide" | "windowed"; + +/** + * A scene-relative layout snapshot. Values are expressed in UIKit points, + * not physical pixels. Use these values instead of device-model checks: the + * same iPad can move between full screen, Split View, and Stage Manager at + * runtime, and future display shapes can expose different safe areas. + */ +export interface LayoutEnvironment { + width: number; + height: number; + aspectRatio: number; + displayScale: number; + horizontalSizeClass: LayoutSizeClass; + verticalSizeClass: LayoutSizeClass; + orientation: "portrait" | "landscape" | "square"; + windowMode: WindowMode; + isMultitasking: boolean; + isFourByThree: boolean; + /** iOS 27 effective scene frame in the system display coordinate space. */ + systemFrameX: number; + systemFrameY: number; + systemFrameWidth: number; + systemFrameHeight: number; + /** Whether iOS 27 is currently delivering an interactive window resize. */ + isInteractivelyResizing: boolean; + /** Whether the scene's interface orientation is currently locked. */ + isInterfaceOrientationLocked: boolean; + safeAreaTop: number; + safeAreaRight: number; + safeAreaBottom: number; + safeAreaLeft: number; +} + +/** Return the current active UIWindowScene's adaptive-layout environment. */ +export function getLayoutEnvironment(): LayoutEnvironment; + +/** + * Subscribe to scene geometry, size-class, and safe-area changes. The handler + * receives an initial snapshot when a scene is available and then only when + * the snapshot changes. Returns a 1-based subscription handle. + */ +export function onLayoutChange( + callback: (environment: LayoutEnvironment) => void, +): number; + +/** Remove a layout subscription. Unknown handles are ignored. */ +export function offLayoutChange(subscription: number): void; + +/** Availability of Apple's default system language model. */ +export type FoundationModelAvailability = + | "available" + | "deviceNotEligible" + | "appleIntelligenceNotEnabled" + | "modelNotReady" + | "unsupported"; + +/** Opaque, process-local Foundation Models session handle. */ +export type LanguageModelSession = number & { + readonly __perryLanguageModelSession: unique symbol; +}; + +/** Query the default model before creating a session. */ +export function foundationModelAvailability(): FoundationModelAvailability; + +/** + * Create a conversational Foundation Models session. Reusing the handle keeps + * the session transcript/context between `respond` calls. An empty instruction + * string creates a session without system instructions. Returns `0` when the + * framework is unavailable on this OS. + */ +export function createLanguageModelSession( + instructions?: string, +): LanguageModelSession; + +/** Generate an unstructured string response for a prompt. */ +export function respond( + session: LanguageModelSession, + prompt: string, +): Promise; + +/** Destroy a session. Pending responses are allowed to finish. */ +export function destroyLanguageModelSession( + session: LanguageModelSession, +): void; diff --git a/types/perry/media/index.d.ts b/types/perry/media/index.d.ts index 296004a23b..64919f8df0 100644 --- a/types/perry/media/index.d.ts +++ b/types/perry/media/index.d.ts @@ -96,7 +96,8 @@ export function onTimeUpdate( * path to a local image or an `https://` URL — the platform backend caches * remote artwork before display. * - * Apple: backed by `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`. + * Apple: backed by an observable NowPlaying `MediaSession` on iOS 27, + * with `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter` as the fallback. * Android: backed by `MediaSessionCompat`. * Linux/GTK4: backed by MPRIS D-Bus. * Windows: backed by `SystemMediaTransportControls`.