diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 986f8dfe68..0b2b1b8fc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3832,7 +3832,13 @@ jobs: # Build them in this SAME Cargo graph as perry-stdlib: otherwise each # no-auto fallback build bundles a distinct tokio TLS/runtime and the # linker rejects the unsafe pair (#507, #7629). - run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p ${{ matrix.ui_backend }} -p perry-doc-tests -p perry-ext-ioredis -p perry-ext-mongodb -p perry-ext-mysql2 -p perry-ext-pg -p perry-ext-nodemailer + run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p ${{ matrix.ui_backend }} -p perry-doc-tests -p perry-ext-ioredis -p perry-ext-mongodb -p perry-ext-mysql2 -p perry-ext-pg -p perry-ext-nodemailer -p perry-ext-net + + - name: Verify macOS application bundle packaging + if: matrix.os == 'macos-14' + env: + RUST_TEST_THREADS: '1' + run: PERRY_RUNTIME_DIR="$PWD/target/release" cargo test --release -p perry --test macos_app_bundle - name: Pre-build Apple UI libs for cross-compile (macOS only) if: matrix.os == 'macos-14' diff --git a/CLAUDE.md b/CLAUDE.md index 3e09cf2e0e..e0f56b89e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1563 +**Current Version:** 0.5.1564 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 04a96ae16c..1a50360303 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5690,7 +5690,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1563" +version = "0.5.1564" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1563" +version = "0.5.1564" [[package]] name = "perry-parser" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1563" +version = "0.5.1564" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1563" +version = "0.5.1564" [[package]] name = "perry-ui-tvos" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1563" +version = "0.5.1564" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 4558229bd5..e62facd0be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1563" +version = "0.5.1564" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10223-large-array-tracing.md b/changelog.d/10223-large-array-tracing.md new file mode 100644 index 0000000000..57919abefc --- /dev/null +++ b/changelog.d/10223-large-array-tracing.md @@ -0,0 +1,6 @@ +Fix garbage collection of large arrays whose backing capacity exceeds their +live length. The collector now bounds the element range by the allocation size +instead of an unrelated capacity cutoff, preserving heap references after array +growth. Regression coverage checks the live range, shared slot descriptors and +child marking at 9, 10 and 16 million elements, plus invalid-capacity and sparse +array bounds. diff --git a/changelog.d/10234-macos-leaf-padding.md b/changelog.d/10234-macos-leaf-padding.md new file mode 100644 index 0000000000..7965cceb43 --- /dev/null +++ b/changelog.d/10234-macos-leaf-padding.md @@ -0,0 +1,6 @@ +Make macOS `setPadding` work on buttons, Text labels, and AttributedText labels, +including intrinsic sizing and asymmetric content placement. Preserve native +button cells, styling, and target/action wiring, and invalidate layout when +padding changes. Respect flipped AppKit coordinates for text-field insets. +Unsupported native views now produce a diagnostic in development builds; wrap +them in a padded stack. Add native size and rendered-content regression tests. diff --git a/changelog.d/10235-loop-lexical-tdz.md b/changelog.d/10235-loop-lexical-tdz.md new file mode 100644 index 0000000000..3226ca698a --- /dev/null +++ b/changelog.d/10235-loop-lexical-tdz.md @@ -0,0 +1,15 @@ +Captured forward `let` and `const` bindings now receive fresh TDZ cells at their +own block entry. Repeated loop entries throw `ReferenceError` before each +declaration, uninitialized `let` declarations end that entry's TDZ with +`undefined`, and retained callbacks keep their original iteration's binding. +Function-scoped `var` bindings continue to share one cell. + +Switch cases allocate one shared lexical environment after the discriminant, +and TDZ cells precede hoisted block-function closures. Code generation also +allocates a fresh cell in every emitted copy of a `finally` block, preserving +the shared stack slot across its normal and exceptional paths. + +Adds HIR and LLVM regressions plus a bounded byte-for-byte Node/native suite +covering script and module contexts at O0/Os/Oz with default and compact GC +configurations, retained callbacks, recursion, skipped declarations, and +exceptional `finally` paths. diff --git a/changelog.d/10236-worker-path-await-helper.md b/changelog.d/10236-worker-path-await-helper.md new file mode 100644 index 0000000000..dcbce9865b --- /dev/null +++ b/changelog.d/10236-worker-path-await-helper.md @@ -0,0 +1,3 @@ +Worker filename discovery now follows awaited synchronous or async helpers whose bodies contain const path bindings and if/return chains. It collects the union of returned paths without evaluating branch conditions, including opaque awaited filesystem probes, while retaining rejection of unsupported return values, mutation, generators, recursion, and bounded expansion limits. + +Missing worker candidates are skipped with a warning and aliases compile as a single worker entry. Multiple existing candidates dispatch using the runtime filename, including URL values; selecting a missing candidate throws instead of starting another worker. This supports OpenCode's TUI worker selector with its compile-time worker-path define and missing distribution-layout fallback. diff --git a/changelog.d/10237-restore-coverage-metrics.md b/changelog.d/10237-restore-coverage-metrics.md new file mode 100644 index 0000000000..c19d8fdb6e --- /dev/null +++ b/changelog.d/10237-restore-coverage-metrics.md @@ -0,0 +1,5 @@ +Correct the `[gc-restore-coverage]` diagnostic to report the actual old-page input (`dirty_old_pages`) separately from raw external entries and the covered-object skip set. The former `dirty_pages` field included external pages that the old-arena walk did not traverse. + +Add admitted parent visits, enumerated slots, and strong slots whose children still require tracking. Slot productivity counts edges even when their page was already dirty; it is separate from `pages_added`. These counters compile out of the diagnostics-off walk, and collection/remembered-set behavior is unchanged. + +A subprocess regression exercises diagnostics on and off, unequal old/external page inputs, duplicate stale external owners, skipped parents, mixed primitive/old/young slots, and repeated repair of an already-dirty page. diff --git a/changelog.d/10238-http-listen-arguments.md b/changelog.d/10238-http-listen-arguments.md new file mode 100644 index 0000000000..5773a0d3dd --- /dev/null +++ b/changelog.d/10238-http-listen-arguments.md @@ -0,0 +1,5 @@ +Fix dynamic HTTP, HTTPS, and HTTP/2 `server.listen()` argument handling. The handle dispatcher passed a stack buffer shaped like an array to the managed-array accessor, which returned `NaN` for both the requested port and completion callback. This could bind the default port and leave the caller waiting for a callback that was lost (#10137). + +Share listen-overload parsing between real runtime arrays and borrowed argument values, then pass the parsed arguments directly to the existing server implementations. Remove the same fabricated-array pattern from `Bun.serve`. Real arrays retain the offset-aware accessor, including arrays whose dense queue prefix has been shifted away; no runtime/codegen array layout changes are needed. + +Tests cover borrowed port/host/backlog/callback overloads and a real shifted argument array. The original HTTP/2 settings/ping/close callback fixture, which timed out before the fix, now matches Node. diff --git a/changelog.d/10240-macos-app-bundles.md b/changelog.d/10240-macos-app-bundles.md new file mode 100644 index 0000000000..fdd539dc60 --- /dev/null +++ b/changelog.d/10240-macos-app-bundles.md @@ -0,0 +1 @@ +- macOS UI builds now produce signed `.app` bundles with app metadata, assets, and localization resources, giving desktop launches a proper application identity (#10078). Explicit `-o Name.app` outputs and `perry run` support the bundle layout; standalone CLI builds keep their existing output. Sandbox and attestation sidecars stay outside the bundle seal. diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index f6e9c4edf8..e6c77523f6 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -17,6 +17,9 @@ use super::{ nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx, I18nLowerCtx, }; +#[path = "worker_new.rs"] +mod worker_new; + /// Build the namespace value for a resolved dynamic-import/require target prefix /// on the current block: a native submodule (`__node_submod__`), a native /// builtin (`__native_mod__`), or a compiled module (`__init` + @@ -488,6 +491,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { options, is_eval: _, } => { + if paths.len() > 1 { + return worker_new::lower_candidates(ctx, paths, filename, options.as_deref()); + } let _ = lower_expr(ctx, filename)?; if ctx.block().is_terminated() { return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); diff --git a/crates/perry-codegen/src/expr/worker_new.rs b/crates/perry-codegen/src/expr/worker_new.rs new file mode 100644 index 0000000000..e4e34b727d --- /dev/null +++ b/crates/perry-codegen/src/expr/worker_new.rs @@ -0,0 +1,131 @@ +//! Runtime selection among the entries discovered by a Worker path helper. +use anyhow::{bail, Result}; +use perry_hir::Expr; + +use crate::expr::FnCtx; +use crate::nanbox::{double_literal, POINTER_TAG_TOP16_I64}; +use crate::rooting::with_rooted_group; +use crate::types::{DOUBLE, I32, I64, PTR, VOID}; + +pub(super) fn lower_candidates( + ctx: &mut FnCtx<'_>, + paths: &[String], + filename: &Expr, + options: Option<&Expr>, +) -> Result { + let targets: Vec<_> = paths + .iter() + .filter_map(|path| ctx.dynamic_import_path_to_prefix.get(path).cloned()) + .collect(); + if targets + .iter() + .any(|target| target.starts_with("__node_submod__") || target.starts_with("__native_mod__")) + { + bail!("worker_threads Worker target must be a compiled source file"); + } + // The driver includes lexical absolute paths for URL values as well as + // import spellings. Sort to keep emitted IR stable. + let mut aliases: Vec<_> = ctx + .dynamic_import_path_to_prefix + .iter() + .filter(|(_, target)| targets.contains(target)) + .map(|(path, target)| (path.clone(), target.clone())) + .collect(); + aliases.sort(); + with_rooted_group(ctx, 2, |ctx, roots| { + let undefined = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let file = roots.lower(ctx, filename, true)?; + if ctx.block().is_terminated() { + return Ok(undefined); + } + let opts = roots.lower(ctx, options.unwrap_or(&Expr::Undefined), true)?; + if ctx.block().is_terminated() { + return Ok(undefined); + } + let file = roots.reread(ctx, file)?; + // The path grammar proves each value is a string or URL. Decode URL + // objects through fileURLToPath, so both encoded and unescaped hrefs + // (including spaces) compare as filesystem paths, without coercion. + let bits = ctx.block().bitcast_double_to_i64(&file); + let tag = ctx.block().lshr(I64, &bits, "48"); + let is_url = ctx.block().icmp_eq(I64, &tag, POINTER_TAG_TOP16_I64); + let normalized = ctx.block().alloca(DOUBLE); + let url_block = ctx.new_block("worker_url"); + let string_block = ctx.new_block("worker_string"); + let dispatch = ctx.new_block("worker_dispatch"); + let url_label = ctx.block_label(url_block); + let string_label = ctx.block_label(string_block); + let dispatch_label = ctx.block_label(dispatch); + ctx.block().cond_br(&is_url, &url_label, &string_label); + ctx.current_block = url_block; + let path = ctx.block().call( + DOUBLE, + "js_url_file_url_to_path", + &[(DOUBLE, &file), (DOUBLE, &undefined)], + ); + ctx.block().store(DOUBLE, &path, &normalized); + ctx.block().br(&dispatch_label); + ctx.current_block = string_block; + ctx.block().store(DOUBLE, &file, &normalized); + ctx.block().br(&dispatch_label); + ctx.current_block = dispatch; + let file = ctx.block().load(DOUBLE, &normalized); + let spec = ctx + .block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &file)]); + // Comparisons below do not allocate. Only the selected spawn can + // collect, after the last use of `spec`; options remain rooted. + let result = ctx.block().alloca(DOUBLE); + let join = ctx.new_block("worker_join"); + for (path, target) in &aliases { + let key = ctx.strings.intern(path); + let global = format!("@{}", ctx.strings.entry(key).handle_global); + let key = ctx.block().load(DOUBLE, &global); + let key = ctx + .block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key)]); + let eq = ctx + .block() + .call(I32, "js_string_equals", &[(I64, &spec), (I64, &key)]); + let matches = ctx.block().icmp_ne(I32, &eq, "0"); + let matched = ctx.new_block("worker_match"); + let next = ctx.new_block("worker_next"); + let matched_label = ctx.block_label(matched); + let next_label = ctx.block_label(next); + ctx.block().cond_br(&matches, &matched_label, &next_label); + ctx.current_block = matched; + // Every worker executes the unguarded body in its own thread. + let init = format!("{target}__init_body"); + ctx.pending_declares.push((init.clone(), VOID, vec![])); + let entry = ctx.block().ptrtoint(&format!("@{init}"), I64); + let options = roots.reread(ctx, opts)?; + let worker = ctx.block().call( + DOUBLE, + "js_worker_threads_worker_new", + &[(I64, &entry), (DOUBLE, &options)], + ); + let join_label = ctx.block_label(join); + ctx.block().store(DOUBLE, &worker, &result); + ctx.block().br(&join_label); + ctx.current_block = next; + } + let message = "worker_threads Worker filename did not match an existing compile-time-resolved worker entry"; + let message_id = ctx.strings.intern(message); + let entry = ctx.strings.entry(message_id); + let global = format!("@{}", entry.bytes_global); + let len = entry.byte_len.to_string(); + ctx.block().call_void( + "js_throw_error_with_code", + &[ + (PTR, &global), + (I64, &len), + (PTR, "null"), + (I64, "0"), + (I32, "0"), + ], + ); + ctx.block().unreachable(); + ctx.current_block = join; + Ok(ctx.block().load(DOUBLE, &result)) + }) +} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index cc27d26763..73e8ecaf05 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -33,6 +33,8 @@ mod loops; mod masked_window_region; #[cfg(test)] mod prealloc_module_global_tests; +#[cfg(test)] +mod prealloc_tdz_path_tests; pub(crate) mod stable_packed_accumulator; pub(crate) mod stable_packed_loop; mod stable_packed_typed_array; @@ -690,14 +692,10 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result if ctx.module_globals.contains_key(id) { continue; } - if ctx.locals.contains_key(id) { - // A previous PreallocateBoxes (or an unusual nesting) - // already set this up -- skip to keep the existing slot. + if !tdz && ctx.locals.contains_key(id) { + // Ordinary preallocation preserves a shared function-scoped cell. ctx.prealloc_boxes.insert(*id); ctx.boxed_vars.insert(*id); - if tdz { - ctx.tdz_boxes.insert(*id); - } continue; } let is_i32_control = crate::expr::is_compiler_private_async_i32_control_local(ctx, *id); @@ -740,19 +738,29 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result "jsvalue_box_cell", ) }; - let slot = ctx.func.alloca_entry(crate::types::I64); - // perry#4926: PreallocateBoxes can sit nested inside an If/Try/Labeled - // body (e.g. the async state-machine wrapper), so this block's - // box-pointer store doesn't necessarily dominate every load of the - // slot. Entry-init the slot to TAG_UNDEFINED so paths that bypass this - // statement read a defined sentinel instead of `undef` (see the boxed - // `Stmt::Let` arm in let_stmt.rs). The slot holds a *box pointer*, not - // the value, so it is TAG_UNDEFINED-initialized in both the TDZ and - // non-TDZ cases -- the TAG_TDZ sentinel lives in the box cell, not the - // slot. - let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); - ctx.func - .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); + // #10051: a TDZ statement creates this entry's lexical environment. + // Emit its allocation even when an earlier COPY of the statement was + // lowered already (normal/exceptional finally paths, for example). + // Reuse the stack slot, but never the previous entry's heap cell: + // retained closures must keep their original binding and value. + let slot = if let Some(slot) = ctx.locals.get(id) { + slot.clone() + } else { + let slot = ctx.func.alloca_entry(crate::types::I64); + // perry#4926: PreallocateBoxes can sit nested inside an If/Try/Labeled + // body (e.g. the async state-machine wrapper), so this block's + // box-pointer store doesn't necessarily dominate every load of the + // slot. Entry-init the slot to TAG_UNDEFINED so paths that bypass this + // statement read a defined sentinel instead of `undef` (see the boxed + // `Stmt::Let` arm in let_stmt.rs). The slot holds a *box pointer*, not + // the value, so it is TAG_UNDEFINED-initialized in both the TDZ and + // non-TDZ cases -- the TAG_TDZ sentinel lives in the box cell, not the + // slot. + let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); + ctx.func + .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); + slot + }; ctx.block().store(crate::types::I64, &box_ptr, &slot); record_boxed_slot_js_value_bits(ctx, *id, &box_ptr, "preallocate_boxes.box_ptr_slot"); if cell_note != "jsvalue_box_cell" { diff --git a/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs b/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs new file mode 100644 index 0000000000..145fe4bee5 --- /dev/null +++ b/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs @@ -0,0 +1,94 @@ +//! #10051: copies of a lexical scope must each execute their TDZ allocation. +use perry_hir::{types::Type, Expr, Function, Module, Stmt}; + +fn emit(body: Vec) -> String { + let mut module = Module::new("tdz_paths.ts"); + module.functions.push(Function { + id: 1, + name: "test".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + String::from_utf8( + crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(), + ) + .unwrap() +} + +fn allocated_slots(ir: &str, seed: &str) -> Vec { + ir.lines() + .filter_map(|line| { + if !line.contains(&format!("call i64 @js_box_alloc_bits(i64 {seed})")) { + return None; + } + let value = line.trim().split(" = ").next().unwrap(); + let prefix = format!("store i64 {value}, ptr "); + Some( + ir.lines() + .find_map(|store| store.trim().strip_prefix(&prefix).map(str::to_string)) + .expect("each allocated box is stored"), + ) + }) + .collect() +} + +#[test] +fn tdz_finally_allocates_on_normal_and_exception_paths() { + let ir = emit(vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(99)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })], + catch: None, + finally: Some(vec![ + Stmt::PreallocateTdzBoxes(vec![10]), + Stmt::Let { + id: 10, + name: "value".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(42)), + }, + ]), + }]); + let slots = allocated_slots(&ir, crate::nanbox::TAG_TDZ_I64); + assert_eq!( + slots.len(), + 2, + "both finally paths need fresh TDZ cells:\n{ir}" + ); + assert_eq!(slots[0], slots[1], "path copies share one stack slot"); + assert!( + ir.contains(&format!( + "store i64 {}, ptr {}", + crate::nanbox::TAG_UNDEFINED_I64, + slots[0] + )), + "slot must be entry-initialized" + ); +} + +#[test] +fn ordinary_preallocation_still_preserves_an_existing_cell() { + let ir = emit(vec![ + Stmt::PreallocateBoxes(vec![10]), + Stmt::PreallocateBoxes(vec![10]), + ]); + assert_eq!( + allocated_slots(&ir, crate::nanbox::TAG_UNDEFINED_I64).len(), + 1, + "ordinary function-scoped cells must not be freshened:\n{ir}" + ); +} diff --git a/crates/perry-ext-http/src/server/bun_server.rs b/crates/perry-ext-http/src/server/bun_server.rs index bf1de40321..e4e8e30ff3 100644 --- a/crates/perry-ext-http/src/server/bun_server.rs +++ b/crates/perry-ext-http/src/server/bun_server.rs @@ -125,13 +125,6 @@ lazy_static! { Mutex::new(HashMap::new()); } -#[repr(C)] -struct InlineArgsHeader { - length: u32, - capacity: u32, - args: [u64; 1], -} - struct ClosureCallResult { value: f64, thrown: Option, @@ -286,12 +279,8 @@ pub unsafe extern "C" fn js_bun_serve(options: f64) -> i64 { crate::server::ensure_gc_scanner_registered(); let handle = register_handle(server); - let args = InlineArgsHeader { - length: 1, - capacity: 1, - args: [options.get().to_bits()], - }; - crate::server::server::js_node_http_server_listen(handle, &args as *const _ as i64); + let parsed = crate::server::types::parse_listen_values(std::iter::once(options.get())); + crate::server::server::listen_http_server(handle, parsed); if !get_handle::(handle) .map(|server| server.listening) .unwrap_or(false) diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 35eab6317a..0f1bf6fb82 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -17,8 +17,8 @@ //! feature, which `optimized_libs.rs` already auto-activates whenever //! `node:http` / `node:https` / `node:http2` is imported) calls //! `js_ext_http_server_is_handle`; on a hit it forwards to -//! `js_ext_http_server_dispatch_method`, which routes to the same -//! `js_node_http_server_*` externs that the static native_table path uses. +//! `js_ext_http_server_dispatch_method`, which routes to the same server +//! implementations that the static native_table path uses. //! //! Issue #2153. @@ -40,7 +40,6 @@ struct ErrorHeader { } extern "C" { - fn js_node_http_server_listen(server_handle: i64, args_array: i64); fn js_node_http_server_close(server_handle: i64, callback: i64); fn js_node_http_server_close_all_connections(handle: i64); fn js_node_http_server_close_idle_connections(handle: i64); @@ -62,7 +61,6 @@ extern "C" { fn js_node_http_server_set_timeout_method(handle: i64, msecs: f64, callback: i64) -> i64; fn js_node_http_server_ref(handle: i64) -> i64; fn js_node_http_server_unref(handle: i64) -> i64; - fn js_node_https_server_listen(server_handle: i64, args_array: i64) -> i64; fn js_node_https_server_close(server_handle: i64, callback: i64); fn js_node_https_server_close_all_connections(handle: i64); fn js_node_https_server_close_idle_connections(handle: i64); @@ -75,7 +73,6 @@ extern "C" { fn js_node_https_server_set_timeout_method(handle: i64, msecs: f64, callback: i64) -> i64; fn js_node_https_server_ref(handle: i64) -> i64; fn js_node_https_server_unref(handle: i64) -> i64; - fn js_node_http2_server_listen(server_handle: i64, args_array: i64) -> i64; fn js_node_http2_server_close(server_handle: i64, callback: i64); fn js_node_http2_server_address_json(handle: i64) -> *mut StringHeader; fn js_node_http2_server_on( @@ -234,19 +231,6 @@ fn http_server_method_bytes(name: &str) -> Option<&'static [u8]> { } } -/// Build a transient `ArrayHeader`-shaped buffer carrying NaN-boxed args. -/// `js_node_http_server_listen` reads its `args_array` arg as a raw -/// `*const ArrayHeader`; the codegen's `NA_VARARGS` path packs one for the -/// direct dispatch, so we mimic that layout here. The buffer lives only -/// for the duration of the call. -#[repr(C)] -struct InlineArgsHeader { - length: u32, - capacity: u32, - // up to 8 packed u64 args follow inline - args: [u64; 8], -} - /// Dispatch a method on a registered `HttpServer` handle. Method name is a /// UTF-8 ptr+len; args are NaN-boxed f64s (the perry-runtime /// `js_native_call_method` shape). Returns NaN-boxed undefined for methods @@ -281,22 +265,13 @@ pub unsafe extern "C" fn js_ext_http_server_dispatch_method( match method.as_str() { "listen" => { - let n = args.len().min(8); - let mut inline = InlineArgsHeader { - length: n as u32, - capacity: n as u32, - args: [0; 8], - }; - for i in 0..n { - inline.args[i] = args[i].to_bits(); - } - let args_array = &inline as *const _ as i64; + let parsed = crate::server::types::parse_listen_values(args.iter().take(8).copied()); if is_h2 { - js_node_http2_server_listen(handle, args_array); + crate::server::http2_server::listen_http2_server(handle, parsed); } else if is_https { - js_node_https_server_listen(handle, args_array); + crate::server::https_server::listen_https_server(handle, parsed); } else { - js_node_http_server_listen(handle, args_array); + crate::server::server::listen_http_server(handle, parsed); } // Node returns the server for chaining (`createServer(...).listen(p).address()`). self_ref diff --git a/crates/perry-ext-http/src/server/http2_server.rs b/crates/perry-ext-http/src/server/http2_server.rs index 6da36ab36d..2b149a274e 100644 --- a/crates/perry-ext-http/src/server/http2_server.rs +++ b/crates/perry-ext-http/src/server/http2_server.rs @@ -491,8 +491,17 @@ pub unsafe extern "C" fn js_node_http2_create_server(first_arg: f64, second_arg: /// / `parse_listen_args` for the overload resolution. Issue #2041. #[no_mangle] pub unsafe extern "C" fn js_node_http2_server_listen(server_handle: i64, args_array: i64) -> i64 { + listen_http2_server( + server_handle, + crate::server::types::parse_listen_args(args_array), + ) +} + +pub(super) unsafe fn listen_http2_server( + server_handle: i64, + parsed: crate::server::types::ListenArgs, +) -> i64 { // Returns `server_handle` for chainability (#2129). - let parsed = crate::server::types::parse_listen_args(args_array); let opts_f64 = parsed.opts; let port = extract_port(opts_f64, 443); let host = parsed diff --git a/crates/perry-ext-http/src/server/https_server.rs b/crates/perry-ext-http/src/server/https_server.rs index 5d58eae7f8..a10d72dd10 100644 --- a/crates/perry-ext-http/src/server/https_server.rs +++ b/crates/perry-ext-http/src/server/https_server.rs @@ -420,8 +420,17 @@ pub(crate) fn set_ticket_keys(server_handle: i64, value: f64) { /// for the overload resolution. Issue #2041. #[no_mangle] pub unsafe extern "C" fn js_node_https_server_listen(server_handle: i64, args_array: i64) -> i64 { + listen_https_server( + server_handle, + crate::server::types::parse_listen_args(args_array), + ) +} + +pub(super) unsafe fn listen_https_server( + server_handle: i64, + parsed: crate::server::types::ListenArgs, +) -> i64 { // Returns `server_handle` for chainability (#2129). - let parsed = crate::server::types::parse_listen_args(args_array); let opts_f64 = parsed.opts; let port = extract_port(opts_f64, 443); let host = parsed diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index c6191c6cbc..92e6ef3d13 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -777,10 +777,19 @@ fn spawn_rr_inject_loop( /// and the (single) function callback wherever it lands. Issue #2041. #[no_mangle] pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_array: i64) -> i64 { + listen_http_server( + server_handle, + crate::server::types::parse_listen_args(args_array), + ) +} + +pub(super) unsafe fn listen_http_server( + server_handle: i64, + parsed: crate::server::types::ListenArgs, +) -> i64 { // Returns `server_handle` so `createServer(...).listen(...).on(...)` chains // correctly. Pre-#2129 this was `-> ()` and chained sites broke at runtime // with `undefined.on is not a function`. - let parsed = crate::server::types::parse_listen_args(args_array); let opts_f64 = parsed.opts; let port = extract_port(opts_f64, 3000); let host = parsed diff --git a/crates/perry-ext-http/src/server/types.rs b/crates/perry-ext-http/src/server/types.rs index 0e5c780e0b..e545aeb9ff 100644 --- a/crates/perry-ext-http/src/server/types.rs +++ b/crates/perry-ext-http/src/server/types.rs @@ -149,25 +149,35 @@ pub struct ListenArgs { /// `listen(cb)` form (first and only arg is a function) is handled too. /// /// # Safety -/// `args_array` must be `0`/null or a valid Perry-runtime `ArrayHeader`. +/// `args_array` must be `0`/null or a live, forwarding-resolved, GC-managed +/// Perry-runtime `ArrayHeader`. Borrowed values use `parse_listen_values`. pub unsafe fn parse_listen_args(args_array: i64) -> ListenArgs { - let mut out = ListenArgs { - opts: f64::from_bits(TAG_UNDEFINED), - host: None, - callback: 0, - }; let arr_ptr = args_array as *const ArrayHeader; if arr_ptr.is_null() { - return out; + return parse_listen_values(std::iter::empty()); } // Codegen passes a clean raw pointer; reject a stray NaN-boxed value // rather than dereferencing tag bits as an address. if (args_array as u64) >> 48 != 0 { - return out; + return parse_listen_values(std::iter::empty()); } let len = (*arr_ptr).length as usize; - for i in 0..len { - let bits = perry_ffi::js_array_get(arr_ptr, i as u32).bits(); + parse_listen_values( + (0..len).map(|i| f64::from_bits(perry_ffi::js_array_get(arr_ptr, i as u32).bits())), + ) +} + +/// Resolve listen overloads from values, without requiring an array container. +/// Dynamic handle dispatch already has a borrowed argument slice; it must not +/// disguise stack storage as a managed `ArrayHeader` (#10137). +pub(super) unsafe fn parse_listen_values(values: impl IntoIterator) -> ListenArgs { + let mut out = ListenArgs { + opts: f64::from_bits(TAG_UNDEFINED), + host: None, + callback: 0, + }; + for (i, value) in values.into_iter().enumerate() { + let bits = value.to_bits(); let v = JsValue::from_bits(bits); // The completion callback is the (single) function argument — match it // by value type, not position, so it's picked up wherever it floats. @@ -400,6 +410,62 @@ mod tests { ); } + extern "C" fn listen_test_callback() -> f64 { + f64::from_bits(TAG_UNDEFINED) + } + + #[test] + fn listen_borrowed_values_preserve_port_host_backlog_and_callback() { + let scope = perry_ffi::TransientRootScope::enter(); + let callback = scope.root_nanbox(f64::from_bits( + JsValue::from_object_ptr(perry_runtime::closure::js_closure_alloc( + listen_test_callback as *const u8, + 0, + )) + .bits(), + )); + let host = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(perry_ffi::alloc_string("127.0.0.1").as_raw()).bits(), + )); + unsafe { + // The dynamic dispatcher receives values with no array/GC header. + let parsed = parse_listen_values([0.0, host.get(), 128.0, callback.get()]); + assert_eq!(parsed.opts.to_bits(), 0.0f64.to_bits()); + assert_eq!(extract_port(parsed.opts, 443), 0); + assert_eq!(parsed.host.as_deref(), Some("127.0.0.1")); + assert_eq!(parsed.callback as u64, callback.get().to_bits() & PTR_MASK); + + let parsed = parse_listen_values([0.0, callback.get()]); + assert_eq!(extract_port(parsed.opts, 443), 0); + assert!(parsed.host.is_none()); + assert_eq!(parsed.callback as u64, callback.get().to_bits() & PTR_MASK); + + let parsed = parse_listen_values([callback.get()]); + assert_eq!(parsed.opts.to_bits(), TAG_UNDEFINED); + assert!(parsed.host.is_none()); + assert_eq!(parsed.callback as u64, callback.get().to_bits() & PTR_MASK); + } + } + + #[test] + fn listen_managed_array_keeps_shifted_element_storage() { + unsafe { + let args = make_args(&[ + JsValue::from_number(999.0), + JsValue::from_number(0.0), + JsValue::from_string_ptr(perry_ffi::alloc_string("127.0.0.1").as_raw()), + ]); + let arr = args as *mut perry_runtime::array::ArrayHeader; + let capacity = (*arr).capacity; + assert_eq!(perry_runtime::array::js_array_shift_f64(arr), 999.0); + assert_eq!((*arr).capacity, capacity - 1, "queue offset must be live"); + let parsed = parse_listen_args(args); + assert_eq!(extract_port(parsed.opts, 443), 0); + assert_eq!(parsed.host.as_deref(), Some("127.0.0.1")); + assert_eq!(parsed.callback, 0); + } + } + /// Encode `bytes` (len ≤ 5) as an inline SSO `SHORT_STRING_TAG` /// NaN-box, mirroring the runtime's `JSValue::try_short_string`: /// tag 0x7FF9, length in bits 40..=47, data little-endian in bits diff --git a/crates/perry-hir/src/dynamic_import/worker_paths.rs b/crates/perry-hir/src/dynamic_import/worker_paths.rs index d6404ff84c..b661f8a7f3 100644 --- a/crates/perry-hir/src/dynamic_import/worker_paths.rs +++ b/crates/perry-hir/src/dynamic_import/worker_paths.rs @@ -15,6 +15,8 @@ type Paths = Result; #[derive(Clone)] struct PathValues { paths: Vec, + // True if ANY candidate is a URL. Mixed return unions may be consumed as + // Worker filenames, but must never be coerced to lexical relative strings. is_url: bool, } @@ -92,6 +94,7 @@ impl> WorkerPaths<'_, V> { fn resolve(&mut self, expr: &Expr, depth: usize) -> Paths { self.tick(depth)?; match expr { + Expr::Await(value) => self.resolve(value, depth + 1), Expr::String(value) => bounded(vec![value.clone()], &mut self.work), Expr::StringCoerce(value) => self.strings(value, depth + 1).map(PathValues::strings), Expr::LocalGet(id) => { @@ -295,7 +298,7 @@ impl> WorkerPaths<'_, V> { .ok_or("call target is mutable or is not a module-local helper")? .borrow(); } - let (id, params, body, asynchronous) = match target { + let (id, params, body, generator) = match target { Expr::FuncRef(id) => { let function = self .module @@ -303,25 +306,19 @@ impl> WorkerPaths<'_, V> { .iter() .find(|function| function.id == *id) .ok_or("call target is not a module-local helper")?; - ( - *id, - &function.params, - &function.body, - function.is_async || function.is_generator || function.was_plain_async, - ) + (*id, &function.params, &function.body, function.is_generator) } Expr::Closure { func_id, params, body, - is_async, is_generator, .. - } => (*func_id, params, body, *is_async || *is_generator), + } => (*func_id, params, body, *is_generator), _ => return Err("opaque call target is not a module-local helper".into()), }; - if asynchronous { - return Err("async and generator helpers are not static path helpers".into()); + if generator { + return Err("generator helpers are not static path helpers".into()); } if params.len() != args.len() || params.iter().any(|p| { @@ -335,9 +332,6 @@ impl> WorkerPaths<'_, V> { "helper requires an exact list of simple static string/URL arguments".into(), ); } - let [Stmt::Return(Some(value))] = body.as_slice() else { - return Err("helper body must contain only a single return (no effects, mutation or multiple returns)".into()); - }; // Resolve arguments before entering the callee so sibling/nested calls // such as identity(identity(path)) are not mistaken for recursion. let mut bindings = Vec::new(); @@ -351,7 +345,16 @@ impl> WorkerPaths<'_, V> { .into_iter() .map(|(id, paths)| (id, self.arguments.insert(id, paths))) .collect(); - let result = self.resolve(value, depth + 1); + let mut values = PathValues::strings(Vec::new()); + let result = self + .returns(body, &mut values, depth + 1) + .and_then(|returns| { + if returns { + Ok(values) + } else { + Err("helper may fall through without returning a path".into()) + } + }); for (id, previous) in saved { if let Some(previous) = previous { self.arguments.insert(id, previous); @@ -362,6 +365,102 @@ impl> WorkerPaths<'_, V> { self.calls.remove(&id); result } + + // Discover edges, not control flow: conditions can contain opaque calls + // (including awaited filesystem probes). Every returned value and every + // const initializer must still belong to the bounded static path grammar. + fn returns( + &mut self, + body: &[Stmt], + values: &mut PathValues, + depth: usize, + ) -> Result { + self.tick(depth)?; + let mut always_returns = false; + for stmt in body { + self.tick(depth)?; + match stmt { + Stmt::Return(Some(value)) => { + let returned = self.resolve(value, depth + 1)?; + values.is_url |= returned.is_url; + for path in returned.paths { + push_path(&mut values.paths, path, &mut self.work)?; + } + always_returns = true; + } + Stmt::Let { + id, + mutable: false, + init: Some(init), + .. + } if self.consts.contains_key(id) => { + self.resolve(init, depth + 1)?; + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.condition(condition, depth + 1)?; + let then_returns = self.returns(then_branch, values, depth + 1)?; + let else_returns = match else_branch { + Some(branch) => self.returns(branch, values, depth + 1)?, + None => false, + }; + always_returns |= then_returns && else_returns; + } + _ => return Err( + "helper body supports only const paths, if and return (no effects or mutation)" + .into(), + ), + } + } + Ok(always_returns) + } + + fn condition(&mut self, expr: &Expr, depth: usize) -> Result<(), String> { + self.tick(depth)?; + if matches!( + expr, + Expr::LocalSet(..) + | Expr::GlobalSet(..) + | Expr::Update { .. } + | Expr::PropertySet { .. } + | Expr::PropertyUpdate { .. } + | Expr::IndexSet { .. } + | Expr::IndexUpdate { .. } + | Expr::StaticFieldSet { .. } + | Expr::WithSet { .. } + | Expr::ClassStaticSymbolSet { .. } + | Expr::SuperPropertySet { .. } + | Expr::ObjectSuperPropertySet { .. } + | Expr::JsSetProperty { .. } + | Expr::PutValueSet { .. } + | Expr::ProxySet { .. } + | Expr::BufferIndexSet { .. } + | Expr::RegExpSetLastIndex { .. } + | Expr::ProcessSetTitle(..) + | Expr::UrlSetHref { .. } + | Expr::UrlSetPathname { .. } + | Expr::UrlSetSearch { .. } + | Expr::UrlSetHash { .. } + | Expr::UrlSetProtocol { .. } + | Expr::UrlSetHostname { .. } + | Expr::UrlSetPort { .. } + | Expr::UrlSetUsername { .. } + | Expr::UrlSetPassword { .. } + | Expr::Delete(..) + ) { + return Err("helper condition contains mutation".into()); + } + let mut result = Ok(()); + walk_expr_children(expr, &mut |child| { + if result.is_ok() { + result = self.condition(child, depth + 1); + } + }); + result + } } fn spend(work: &mut usize) -> Result<(), String> { diff --git a/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs index 972831e4d2..d4082e9b11 100644 --- a/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs +++ b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs @@ -93,11 +93,10 @@ fn unsafe_helpers_stay_unresolved_with_reasons() { for body in [ "console.log('effect'); return './worker.js';", "let x = './worker.js'; x = './other.js'; return x;", - "if (true) return './worker.js'; return './other.js';", ] { rejected( &format!("function entry() {{ {body} }} new Worker(entry());"), - "single return", + "no effects or mutation", ); } rejected( @@ -114,8 +113,8 @@ fn unsafe_helpers_stay_unresolved_with_reasons() { "exact list", ); rejected( - "const entry = async () => './worker.js'; new Worker(entry());", - "async", + "function* entry() { return './worker.js'; } new Worker(entry());", + "generator", ); rejected( "const entry = (x = './worker.js') => x; new Worker(entry());", @@ -214,3 +213,69 @@ fn branching_helper_expansion_has_a_shared_work_budget() { source.push_str("new Worker(h12());"); rejected(&source, "work limit"); } + +#[test] +fn awaited_helpers_and_if_return_unions() { + paths( + r#" + const WORKER_PATH = '/source/worker.ts'; + async function target() { + if (typeof WORKER_PATH !== 'undefined') return WORKER_PATH; + const dist = new URL('../x/worker.js', import.meta.url); + if (await exists(dist)) return dist; + return new URL('./worker.ts', import.meta.url); + } + const file = await target(); + new Worker(file); + "#, + &["/source/worker.ts", "../x/worker.js", "./worker.ts"], + ); + paths( + "async function entry() { return './worker.js'; } new Worker(await entry());", + &["./worker.js"], + ); + paths( + "const entry = async () => './worker.js'; new Worker(await entry());", + &["./worker.js"], + ); + paths("new Worker(await './worker.js');", &["./worker.js"]); + paths( + "function entry() { if (opaque()) return './a.js'; return './b.js'; } new Worker(entry());", + &["./a.js", "./b.js"], + ); + paths("function entry(path) { const file = path + '.js'; if (opaque()) { return file; } else { return './b.js'; } } new Worker(entry('./a'));", &["./a.js", "./b.js"]); +} + +#[test] +fn return_union_rejections_and_limits() { + rejected("async function entry() { if (await opaque()) return './a.js'; return opaque(); } new Worker(await entry());", "opaque call"); + rejected("async function entry() { if (true) return './a.js'; return await entry(); } new Worker(await entry());", "recursive"); + rejected( + "function entry() { if (opaque()) return './a.js'; } new Worker(entry());", + "fall through", + ); + rejected( + "function entry() { if (opaque()) return; return './a.js'; } new Worker(entry());", + "no effects or mutation", + ); + rejected( + "function entry() { const ignored = opaque(); return './a.js'; } new Worker(entry());", + "opaque call", + ); + rejected("function entry() { let path = './a.js'; if (path = './b.js') return './a.js'; return './b.js'; } new Worker(entry());", "no effects or mutation"); + rejected("function entry() { if (obj.x = true) return './a.js'; return './b.js'; } new Worker(entry());", "mutation"); + rejected("function entry() { const url = new URL('./a.js', import.meta.url); if (url.href = 'file:///b.js') return url; return './c.js'; } new Worker(entry());", "mutation"); + rejected("function entry() { if (opaque()) return './a.js'; return new URL('./b.js', import.meta.url); } new Worker('./prefix' + entry());", "URL string coercion"); + let branches = (0..=DYNAMIC_IMPORT_PATH_CAP) + .map(|n| format!("if (opaque()) return './w{n}.js';")) + .collect::(); + rejected( + &format!("function entry() {{ {branches} return './last.js'; }} new Worker(entry());"), + "candidate count", + ); + let branches = "if (opaque()) return './w.js';".repeat(WORK_LIMIT); + rejected( + &format!("function entry() {{ {branches} return './last.js'; }} new Worker(entry());"), + "work limit", + ); +} diff --git a/crates/perry-hir/src/ir/stmt.rs b/crates/perry-hir/src/ir/stmt.rs index 2ce2a54c33..cdd77f4163 100644 --- a/crates/perry-hir/src/ir/stmt.rs +++ b/crates/perry-hir/src/ir/stmt.rs @@ -72,6 +72,9 @@ pub enum Stmt { /// read of such a box before its `Stmt::Let` runs throws a spec /// ReferenceError; the `Stmt::Let` (or `let x;` with no init) overwrites /// the sentinel with the real value / `undefined`, ending the dead zone. + /// Nested lexical scopes emit this at block entry: every execution must + /// allocate a fresh cell, even when codegen emits multiple copies of that + /// block (such as a finally body on normal and exceptional paths). PreallocateTdzBoxes(Vec), /// Hand the heap box cells behind a set of boxed LocalIds to the async /// activation lifetime tracker (#7933 / #8213). A cell no closure captures diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index ab3173ac7d..03a1798a94 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1211,6 +1211,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul &combined, &hoisted_id_set, ); + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); for id in &forward_boxed_ids { if !prealloc.contains(id) { prealloc.push(*id); diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 2d1c6992a7..67fc367fb1 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -593,6 +593,8 @@ pub struct LoweringContext { /// enclosing scope). Without this, a same-named `let` in a sibling block /// was skipped (deduped by name) and any post-block reference of the name /// resolved to the block's box instead of the outer binding. + /// Their TDZ cells are also allocated at block entry, rather than function + /// entry, to preserve per-entry binding identity and the TDZ in loops. pub(crate) nested_forward_scope_ids: HashSet, /// Shadow index: function name -> index in `functions` Vec (last entry for shadowing) pub(crate) functions_index: HashMap, diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index b9b3a3276a..7159c9469d 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1856,7 +1856,7 @@ pub(crate) fn lower_stmt( module.init.push(Stmt::Throw(expr)); } ast::Stmt::Switch(switch_stmt) => { - let discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; + let mut discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; let mut cases = Vec::new(); let switch_scope_mark = ctx.push_block_scope(); // Case statement-lists share the switch's block scope without @@ -1868,8 +1868,9 @@ pub(crate) fn lower_stmt( // one shared scope key: a second case re-declaring the name is a // redeclaration, not a shadow. let mut saved_class_renames = Vec::new(); + let mut tdz_boxes = Vec::new(); for case in &switch_stmt.cases { - rebind_nested_forward_scope_lets(ctx, &case.cons); + tdz_boxes.extend(rebind_nested_forward_scope_lets(ctx, &case.cons)); saved_class_renames.extend(enter_class_rename_scope( ctx, switch_stmt.span.lo.0, @@ -1891,6 +1892,20 @@ pub(crate) fn lower_stmt( exit_class_rename_scope(ctx, saved_class_renames); ctx.pop_block_scope(switch_scope_mark); + if !tdz_boxes.is_empty() { + // Evaluate the discriminant before entering the shared case + // environment; fallthrough must not allocate a second cell. + let id = ctx.fresh_local(); + module.init.push(Stmt::Let { + id, + name: "__switch_discriminant".into(), + ty: Type::Any, + mutable: false, + init: Some(discriminant), + }); + module.init.push(Stmt::PreallocateTdzBoxes(tdz_boxes)); + discriminant = Expr::LocalGet(id); + } module.init.push(Stmt::Switch { discriminant, cases, diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index be343d2a5c..e9403f5abd 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -21,7 +21,7 @@ pub(crate) use var_names::{ }; pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result> { - rebind_nested_forward_scope_lets(ctx, &block.stmts); + let tdz_boxes = rebind_nested_forward_scope_lets(ctx, &block.stmts); // #9466: `class` is block-scoped, so a `class X` here is a DISTINCT class // from any enclosing/prior `class X` and needs its own registration key. // This is the funnel every `{}`-shaped scope shares — bare block, `if` / @@ -37,7 +37,12 @@ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts); let lowered = lower_stmts_using_aware(ctx, &block.stmts); exit_class_rename_scope(ctx, saved_class_renames); - lowered + lowered.map(|mut body| { + if !tdz_boxes.is_empty() { + body.insert(0, Stmt::PreallocateTdzBoxes(tdz_boxes)); + } + body + }) } /// Make the forward-captured `let`/`const` bindings that @@ -50,15 +55,22 @@ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re /// unwinds, so the binding is visible exactly within its block — a same-named /// `let` in a sibling block gets its own id/box, and references after the /// block resolve to the outer binding (or stay global) as in Node. +/// Returns the cells to allocate at this scope's runtime entry. In particular, +/// a loop must allocate NEW cells on every entry, both to restart the TDZ and +/// to leave callbacks from previous iterations attached to their original cells. /// /// Called from [`lower_block_stmt`] (every `{}`-shaped scope: block, `try` / /// `catch` / `finally`, block-bodied `if` / loop / labeled bodies) and from /// the two switch-case lowering arms (`lower/stmt.rs`, `lower_decl/ /// body_stmt.rs`), whose case statement-lists share the switch's block scope /// without being a `BlockStmt`. -pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: &[ast::Stmt]) { +pub(crate) fn rebind_nested_forward_scope_lets( + ctx: &mut LoweringContext, + stmts: &[ast::Stmt], +) -> Vec { + let mut tdz_boxes = Vec::new(); if ctx.lexical_forward_decls.is_empty() { - return; + return tdz_boxes; } for stmt in stmts { let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt else { @@ -77,11 +89,13 @@ pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: if let Some(&id) = ctx.lexical_forward_decls.get(&span_lo) { if ctx.nested_forward_scope_ids.contains(&id) { ctx.locals.push((name, id, Type::Any)); + tdz_boxes.push(id); } } } } } + tdz_boxes } /// Collect identifier names referenced INSIDE any closure (arrow / function @@ -108,7 +122,9 @@ pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: /// scope local now (so the earlier closure resolves it to the local and /// captures the live box) and span-keyed in `lexical_forward_decls` so the /// declaration — including a destructuring leaf — reuses the same id. Returns -/// the pre-registered ids so the caller can prealloc their boxes at entry. +/// the function-scoped ids so the caller can prealloc their boxes at function +/// entry. Nested lexical ids are allocated at their own scope's entry by +/// `rebind_nested_forward_scope_lets`'s callers. /// /// `body_entry_locals_len` is `ctx.locals.len()` captured before any of this /// body's own locals were defined — anything at or above it is in THIS scope, @@ -133,9 +149,9 @@ pub(crate) fn pre_register_forward_captured_lets( // `try { let cb = () => x; let x = …; cb() }` (esbuild `__esm` streaming // closures in the compiled query async-generator) fell through to // `js_global_get_or_throw_unresolved` → `ReferenceError: x is not - // defined`. Forward-captured boxes from any depth still preallocate at - // function entry (Phase 4/5) and each declaration reuses its id by span - // (`lexical_forward_decls`). + // defined`. Function-scoped boxes preallocate at function entry (Phase + // 4/5); nested lexical boxes at their own block entry. Each declaration + // reuses its id by span (`lexical_forward_decls`). // // The bool is `is_nested`: only the function-body top level (front entry) // defines its pre-registrations as name-visible function-scope locals. @@ -220,7 +236,6 @@ pub(crate) fn pre_register_forward_captured_lets( ctx.var_hoisted_ids.insert(id); ctx.tdz_forward_ids.insert(id); ctx.nested_forward_scope_ids.insert(id); - forward_boxed_ids.push(id); ctx.lexical_forward_decls.insert(span_lo, id); registered_here.insert(name); } else { @@ -756,6 +771,7 @@ pub fn lower_fn_body_block_stmt( // the box before the declaration assigns through it. let combined: Vec = hoisted_lets.iter().chain(other.iter()).cloned().collect(); let mut prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_id_set); + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); for id in forward_boxed_ids { if !prealloc.contains(&id) { prealloc.push(id); @@ -1065,7 +1081,7 @@ pub fn lower_block_stmt_scoped( fn lower_block_fn_decls(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result> { use std::collections::{HashMap, HashSet}; - rebind_nested_forward_scope_lets(ctx, &block.stmts); + let tdz_boxes = rebind_nested_forward_scope_lets(ctx, &block.stmts); let mut hoisted_ids = HashSet::new(); let mut block_ids = HashMap::new(); @@ -1089,7 +1105,11 @@ fn lower_block_fn_decls(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re hoisted_ids.insert(id); } if hoisted_ids.is_empty() { - return lower_stmts_using_aware(ctx, &block.stmts); + let mut body = lower_stmts_using_aware(ctx, &block.stmts)?; + if !tdz_boxes.is_empty() { + body.insert(0, Stmt::PreallocateTdzBoxes(tdz_boxes)); + } + return Ok(body); } // Lower in source order first: a declaration body may capture lexical @@ -1120,8 +1140,15 @@ fn lower_block_fn_decls(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re } let combined: Vec<_> = hoisted.iter().chain(other.iter()).cloned().collect(); - let prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_ids); + let mut prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_ids); + // A hoisted closure may capture a forward lexical from this block. Its + // TDZ cell is already allocated here; never replace it with an ordinary + // undefined-seeded cell or hoist a nested block's cell into this scope. + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); let mut result = Vec::new(); + if !tdz_boxes.is_empty() { + result.push(Stmt::PreallocateTdzBoxes(tdz_boxes)); + } if !prealloc.is_empty() { result.push(Stmt::PreallocateBoxes(prealloc)); } diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 5c675a03a3..d339267582 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1012,7 +1012,7 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - let discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; + let mut discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; let mut cases = Vec::new(); let switch_scope_mark = ctx.push_block_scope(); // Case statement-lists share the switch's block scope without @@ -1024,8 +1024,11 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Vec { + let parsed = parse_typescript(source, "loop-tdz.ts").expect("parse"); + lower_module(&parsed, "test", "loop-tdz.ts") + .expect("lower") + .functions + .into_iter() + .find(|f| f.name == "test") + .expect("test function") + .body +} + +fn local(stmts: &[Stmt], name: &str) -> LocalId { + stmts + .iter() + .find_map(|s| match s { + Stmt::Let { id, name: n, .. } if n == name => Some(*id), + _ => None, + }) + .expect("local declaration") +} + +#[test] +fn loop_lexicals_allocate_at_block_entry_but_vars_at_function_entry() { + for directive in ["", "'use strict';"] { + let stmts = body(&format!( + r#" + function test() {{ + {directive} + for (let i = 0; i < 2; i++) {{ + const read = () => [value, shared]; + let value; + var shared = i; + read(); + }} + }} + "# + )); + let loop_body = stmts + .iter() + .find_map(|s| match s { + Stmt::For { body, .. } => Some(body), + _ => None, + }) + .expect("loop"); + let value = local(loop_body, "value"); + let shared = local(&stmts, "shared"); + assert!( + matches!(loop_body.first(), Some(Stmt::PreallocateTdzBoxes(ids)) if ids.contains(&value)), + "lexical cell must be created on each loop entry: {stmts:?}" + ); + assert!( + !stmts + .iter() + .any(|s| matches!(s, Stmt::PreallocateTdzBoxes(ids) if ids.contains(&value))), + "nested lexical cell must not be allocated at function entry" + ); + assert!( + stmts + .iter() + .any(|s| matches!(s, Stmt::PreallocateBoxes(ids) if ids.contains(&shared))), + "var keeps one function-scoped cell" + ); + } +} + +#[test] +fn strict_hoisted_closure_captures_the_block_entry_tdz_cell() { + let stmts = body( + r#" + function test() { + 'use strict'; + while (true) { + read(); + function read() { return value; } + let value = 1; + break; + } + } + "#, + ); + let loop_body = stmts + .iter() + .find_map(|s| match s { + Stmt::While { body, .. } => Some(body), + _ => None, + }) + .expect("loop"); + let value = local(loop_body, "value"); + assert!( + matches!(loop_body.first(), Some(Stmt::PreallocateTdzBoxes(ids)) if ids.contains(&value)), + "TDZ cell must precede hoisted closures: {stmts:?}" + ); + assert!( + !loop_body + .iter() + .any(|s| matches!(s, Stmt::PreallocateBoxes(ids) if ids.contains(&value))), + "ordinary closure preallocation must not duplicate the TDZ cell" + ); +} diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index 2f32418676..b8b49350ea 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -37,7 +37,9 @@ pub(crate) unsafe fn gc_element_slot_range( } let length = (*arr).length as usize; let capacity = (*arr).capacity as usize; - if capacity > 16_000_000 || capacity > super::array_physical_capacity(arr) { + // Capacity includes growth slack. Only the allocation bounds it: a valid + // live prefix must remain visible to every GC walk after a grow. + if capacity > super::array_physical_capacity(arr) { return None; } if length > capacity { diff --git a/crates/perry-runtime/src/gc/tests/layout_trace.rs b/crates/perry-runtime/src/gc/tests/layout_trace.rs index 0ebb286208..ff187248aa 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace.rs @@ -3,6 +3,7 @@ use super::support::*; mod array_layout; mod declared_at_allocation; mod element_shape; +mod large_array_slots; mod object_closure_slots; mod object_layout_invalidation; mod per_object_tables; diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/large_array_slots.rs b/crates/perry-runtime/src/gc/tests/layout_trace/large_array_slots.rs new file mode 100644 index 0000000000..07883b9e2c --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/layout_trace/large_array_slots.rs @@ -0,0 +1,86 @@ +//! Array tracing is bounded by allocated storage, including unused growth slack. +//! These checks inspect ranges and marking without sweeping or moving objects. + +use super::*; + +#[test] +fn large_array_live_prefix_is_enumerated_and_marked() { + let _isolation = copying_nursery_isolation_lock(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + clear_marks(); + clear_mark_seeds(); + + // The capacity produced by geometric growth is larger than every tested + // live prefix. Allocate it once; the unused slots remain initialized holes. + let arr = crate::array::js_array_alloc(1 << 24); + let header = unsafe { header_from_user_ptr(arr.cast()) }; + for length in [9_000_000, 10_000_000, 16_000_000] { + unsafe { + (*arr).length = length; + let range = crate::array::gc_element_slot_range(arr) + .expect("a valid allocation must expose its live prefix"); + assert_eq!(range.slot_count(), length as usize); + } + + let text = b"large-array-live-child"; + let child = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let child_header = unsafe { header_from_user_ptr(child.cast()) }; + let index = length - 1; + crate::array::js_array_set_f64(arr, 0, 42.0); + crate::array::js_array_set_f64(arr, index, crate::value::js_nanbox_string(child as i64)); + + // Visit the collector's shared descriptors, not just the range helper. + // Mark, copy, rewrite and dirty-slot scans all consume this slot set. + let expected_slot = unsafe { crate::array::array_elements_ptr(arr).add(index as usize) }; + let mut found = false; + unsafe { + visit_gc_rewrite_slot_descriptors(header, |descriptor| { + descriptor.visit_slots(&mut |slot| { + found |= slot.slot == expected_slot; + }); + }); + } + assert!(found, "the last live element must be enumerated"); + + let valid_ptrs = build_valid_pointer_set(); + let mut worklist = Vec::new(); + unsafe { + trace_array(arr.cast(), &valid_ptrs, &mut worklist); + assert_ne!((*child_header).gc_flags & GC_FLAG_MARKED, 0); + } + clear_marks(); + clear_mark_seeds(); + } +} + +#[test] +fn array_slot_range_preserves_allocation_and_sparse_bounds() { + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = crate::array::js_array_alloc(16); + unsafe { + let capacity = (*arr).capacity; + (*arr).length = 3; + (*arr).capacity = capacity + 1; + assert!(crate::array::gc_element_slot_range(arr).is_none()); + (*arr).capacity = capacity; + + // Sparse logical length does not authorize scanning outside storage. + (*arr).length = u32::MAX; + assert_eq!( + crate::array::gc_element_slot_range(arr) + .unwrap() + .slot_count(), + capacity as usize + ); + // A consumed queue prefix reduces the remaining dense capacity. + (*arr).capacity -= 4; + assert_eq!( + crate::array::gc_element_slot_range(arr) + .unwrap() + .slot_count(), + (capacity - 4) as usize + ); + (*arr).length = 0; + (*arr).capacity = capacity; + } +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index b7555bde71..ec960abbbc 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -50,6 +50,7 @@ mod oldgen; mod os_tag; mod promote_in_place; mod proxy_registry; +mod restore_coverage; mod retention_9628_9629; mod root_words; mod rooted_container_values; diff --git a/crates/perry-runtime/src/gc/tests/restore_coverage.rs b/crates/perry-runtime/src/gc/tests/restore_coverage.rs new file mode 100644 index 0000000000..45b3ceabfa --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/restore_coverage.rs @@ -0,0 +1,115 @@ +//! #9877: the diagnostic must describe the actual repair walk and its slots. +use super::super::*; +use super::support::*; + +fn exercise_restore() { + let _isolation = copying_nursery_isolation_lock(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + reset_remembered_set(); + + unsafe { + // Two walked parents have four slots, but only two young edges. + // The third parent's primitive slot is already covered by the scan. + let (one, one_slot) = alloc_old_test_object(1); + let (three, three_slots) = alloc_old_test_object(3); + let (skipped, skipped_slot) = alloc_old_test_object(1); + let (young, _) = alloc_nursery_test_object(0); + assert!(!crate::arena::pointer_in_old_gen(young as usize)); + let young_bits = POINTER_TAG | (young as u64 & POINTER_MASK); + *one_slot = young_bits; + *three_slots = young_bits; + *three_slots.add(1) = POINTER_TAG | (one as u64 & POINTER_MASK); + *three_slots.add(2) = 42.0f64.to_bits(); + *skipped_slot = 7.0f64.to_bits(); + + let page = crate::arena::generation_page_for_addr(one as usize); + assert_eq!(page, crate::arena::generation_page_for_addr(three as usize)); + assert_eq!( + page, + crate::arena::generation_page_for_addr(skipped as usize) + ); + let snapshot = RememberedDirtySnapshot { + dirty_old_pages: [page].into_iter().collect(), + // A repeated stale external owner is deduplicated, then rejected + // without dereferencing it. These pages are not old walk inputs. + external_dirty_entries: vec![(page + 1, 0), (page + 2, 0)], + dirty_pages: [page, page + 1, page + 2].into_iter().collect(), + fallback_headers: Vec::new(), + }; + let covered = [header_from_user_ptr(skipped as *const u8) as usize] + .into_iter() + .collect(); + remembered_set_clear(); + restore_surviving_dirty_coverage(&snapshot, &covered, "first"); + assert!( + barrier::DIRTY_OLD_PAGES.with(|s| s.borrow().contains(&page)), + "repair must restore the page containing the young edges" + ); + // Productivity counts edges, even when their page is already dirty. + restore_surviving_dirty_coverage(&snapshot, &covered, "repeat"); + assert_eq!(barrier::DIRTY_OLD_PAGES.with(|s| s.borrow().len()), 1); + } + remembered_set_clear(); + println!("restore coverage witness completed"); +} + +fn field(line: &str, name: &str) -> usize { + let prefix = format!("{name}="); + line.split_whitespace() + .find_map(|word| word.strip_prefix(&prefix)) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("missing numeric {name} in {line}")) +} + +#[test] +fn restore_coverage_diagnostic_matches_walk_and_off_arm() { + const CHILD: &str = "PERRY_TEST_RESTORE_COVERAGE_CHILD"; + let thread = std::thread::current(); + let name = thread.name().expect("libtest thread name"); + if std::env::var(CHILD).ok().as_deref() == Some(name) { + exercise_restore(); + return; + } + for enabled in ["0", "1"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(CHILD, name) + .env("PERRY_GC_DIAG", enabled) + .output() + .expect("run isolated diagnostic witness"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.contains("restore coverage witness completed"), + "witness failed (diag={enabled}): {}\n{stdout}\n{stderr}", + output.status + ); + let lines: Vec<_> = stderr + .lines() + .filter(|line| line.starts_with("[gc-restore-coverage] ")) + .collect(); + if enabled == "0" { + assert!( + lines.is_empty(), + "diagnostics off must stay silent: {stderr}" + ); + continue; + } + assert_eq!(lines.len(), 2, "one diagnostic per repair: {stderr}"); + for (line, added) in lines.iter().zip([1, 0]) { + assert_eq!(field(line, "dirty_old_pages"), 1); + assert_eq!(field(line, "external_entries"), 2); + assert_eq!(field(line, "covered"), 1); + assert_eq!(field(line, "objects_walked"), 3); + assert_eq!(field(line, "objects_skipped"), 1); + assert_eq!(field(line, "parents_visited"), 2); + assert_eq!(field(line, "slots_visited"), 4); + assert_eq!(field(line, "slots_tracking"), 2); + assert_eq!(field(line, "pages_added"), added); + assert!( + !line.contains(" dirty_pages="), + "do not mislabel the walk input" + ); + } + } +} diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index f9add564d7..36e6038046 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -232,9 +232,9 @@ pub(super) unsafe fn remember_evacuated_old_to_young_slot( sticky: &mut StickyRememberedSet, parent_header: *mut GcHeader, slot: *mut u64, -) { +) -> bool { if slot.is_null() { - return; + return false; } let child_addr = decode_heap_addr(*slot); // Nursery AND malloc-GC children both need their pages kept dirty: @@ -242,13 +242,17 @@ pub(super) unsafe fn remember_evacuated_old_to_young_slot( // leaves — dropping an old→malloc page here would free the malloc // child on the next minor (see remembered_child_needs_tracking). if child_addr == 0 || !crate::gc::barrier::remembered_child_needs_tracking(child_addr) { - return; + return false; } sticky.remember_slot( parent_header, slot, slot_is_external_to(parent_header, slot), ); + // Report the child's tracking requirement, not whether its page was new. + // The repair diagnostic can count productive slots without decoding and + // classifying the same child a second time. + true } /// Is `slot` outside `parent_header`'s own allocation, or on a page the @@ -336,10 +340,27 @@ pub(super) fn restore_surviving_dirty_coverage( snapshot: &RememberedDirtySnapshot, covered: &crate::fast_hash::PtrHashSet, cycle_label: &str, +) { + // Keep slot accounting out of the normal GC walk. Both instantiations + // perform exactly the same repair; only the diagnostic one counts it. + if crate::gc::gc_diag_enabled() { + restore_surviving_dirty_coverage_impl::(snapshot, covered, cycle_label); + } else { + restore_surviving_dirty_coverage_impl::(snapshot, covered, cycle_label); + } +} + +fn restore_surviving_dirty_coverage_impl( + snapshot: &RememberedDirtySnapshot, + covered: &crate::fast_hash::PtrHashSet, + cycle_label: &str, ) { let mut sticky = StickyRememberedSet::default(); let mut walked = 0usize; let mut skipped = 0usize; + let mut parents_visited = 0usize; + let mut slots_visited = 0usize; + let mut slots_tracking = 0usize; #[cfg(debug_assertions)] let mut skipped_sticky = StickyRememberedSet::default(); // Mirror scan_remembered_dirty_slots_copying's scan_header guards: the @@ -367,12 +388,23 @@ pub(super) fn restore_surviving_dirty_coverage( { return; } + if DIAGNOSTICS { + parents_visited += 1; + } visit_gc_rewrite_slots(header, |slot| { + if DIAGNOSTICS { + // Count all enumerated slots, including unproductive weak + // targets and primitive values. This measures traversal work. + slots_visited += 1; + } if crate::weakref::is_weak_target_trace_slot(header, slot.slot) { return; } slot.record_layout_read(); - remember_evacuated_old_to_young_slot(&mut sticky, header, slot.slot); + let tracking = remember_evacuated_old_to_young_slot(&mut sticky, header, slot.slot); + if DIAGNOSTICS && tracking { + slots_tracking += 1; + } }); }; if !snapshot.dirty_old_pages.is_empty() { @@ -429,10 +461,19 @@ pub(super) fn restore_surviving_dirty_coverage( object `scan_dirty_object_slots` reported complete" ); } - if crate::gc::gc_diag_enabled() { + if DIAGNOSTICS { + // These are the two actual snapshot inputs and the skip-set size. + // `dirty_pages` also includes external pages and is NOT the set the + // old-arena walk iterates. Candidate counts precede validity guards; + // parent visits and slot counts describe the admitted traversal. eprintln!( - "[gc-restore-coverage] {cycle_label} dirty_pages={} objects_walked={walked} objects_skipped={skipped} pages_added={added}", - snapshot.dirty_pages.len() + "[gc-restore-coverage] {cycle_label} dirty_old_pages={} external_entries={} \ + covered={} objects_walked={walked} objects_skipped={skipped} \ + parents_visited={parents_visited} slots_visited={slots_visited} \ + slots_tracking={slots_tracking} pages_added={added}", + snapshot.dirty_old_pages.len(), + snapshot.external_dirty_entries.len(), + covered.len(), ); } } diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index ea8294a1de..332d166c05 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -80,3 +80,8 @@ perry-runtime.workspace = true name = "native_widget_order" path = "tests/native_widget_order.rs" harness = false + +[[test]] +name = "native_widget_padding" +path = "tests/native_widget_padding.rs" +harness = false diff --git a/crates/perry-ui-macos/src/drag_drop.rs b/crates/perry-ui-macos/src/drag_drop.rs index 26fb12c36d..261da4b368 100644 --- a/crates/perry-ui-macos/src/drag_drop.rs +++ b/crates/perry-ui-macos/src/drag_drop.rs @@ -112,13 +112,24 @@ unsafe fn call_provider(cb: f64) -> Option { /// to it (idempotent). unsafe fn ensure_swizzled(view: *mut AnyObject) { let cls = (*view).class(); - if cls.name().to_bytes().starts_with(b"PerryDragDrop_") { + if drag_drop_class(cls).is_some() { return; // already swizzled } let sub = get_or_create_subclass(cls); objc2::ffi::object_setClass(view, sub as *const AnyClass as *mut AnyClass); } +// Padding can add a subclass above ours. Find the class that owns our +// methods instead of wrapping it twice or losing mouseDown forwarding. +fn drag_drop_class(mut cls: &'static AnyClass) -> Option<&'static AnyClass> { + loop { + if cls.name().to_bytes().starts_with(b"PerryDragDrop_") { + return Some(cls); + } + cls = cls.superclass()?; + } +} + unsafe fn get_or_create_subclass(orig: &AnyClass) -> &'static AnyClass { let sub_name = format!("PerryDragDrop_{}", orig.name().to_str().unwrap_or("View")); let c_name = CString::new(sub_name).unwrap(); @@ -300,7 +311,7 @@ extern "C-unwind" fn mouse_down(this: *mut AnyObject, cmd: Sel, event: *mut AnyO // Not a drag source — forward to the original class's mouseDown: so the // underlying control (button, text field, …) keeps behaving normally. unsafe { - let sub = (*this).class(); + let sub = drag_drop_class((*this).class()).expect("drag/drop method owner"); let imp = ORIG_MOUSEDOWN.with(|m| m.borrow().get(&(sub as *const AnyClass as usize)).copied()); if let Some(imp) = imp { diff --git a/crates/perry-ui-macos/src/widgets/attributed_text.rs b/crates/perry-ui-macos/src/widgets/attributed_text.rs index 4ee901df96..d83e12b70d 100644 --- a/crates/perry-ui-macos/src/widgets/attributed_text.rs +++ b/crates/perry-ui-macos/src/widgets/attributed_text.rs @@ -34,6 +34,7 @@ pub fn create() -> i64 { let mtm = MainThreadMarker::new().expect("perry/ui must run on the main thread"); let empty = NSString::from_str(""); let label = NSTextField::labelWithString(&empty, mtm); + super::padding::install_label_cell(&label, mtm); unsafe { let _: () = msg_send![&*label, setTranslatesAutoresizingMaskIntoConstraints: false]; // Enable wrapping by default — per-range styling is most useful diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index 49112aa37c..de62433062 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -6,6 +6,8 @@ use objc2_core_foundation::CGRect; use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol}; use std::cell::Cell; +mod button; + pub struct PerryInsetCellIvars { top: Cell, left: Cell, @@ -49,14 +51,14 @@ define_class!( impl PerryInsetTextFieldCell { #[unsafe(method(drawingRectForBounds:))] fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect { - let bounds = inset_rect(bounds, self.ivars().get()); + let bounds = inset_rect(bounds, self.ivars().get(), unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); - let bounds = inset_rect(bounds, insets); + let bounds = inset_rect(bounds, insets, unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); let size: objc2_core_foundation::CGSize = unsafe { msg_send![super(self), cellSizeForBounds: bounds] }; padded_size(size, insets) @@ -71,7 +73,7 @@ define_class!( delegate: Option<&AnyObject>, event: Option<&NSEvent>, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), editWithFrame: frame, inView: view, editor: editor, delegate: delegate, event: event]; } @@ -85,7 +87,7 @@ define_class!( start: isize, length: isize, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), selectWithFrame: frame, inView: view, editor: editor, delegate: delegate, start: start, length: length]; } @@ -105,14 +107,14 @@ define_class!( impl PerryInsetSecureTextFieldCell { #[unsafe(method(drawingRectForBounds:))] fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect { - let bounds = inset_rect(bounds, self.ivars().get()); + let bounds = inset_rect(bounds, self.ivars().get(), unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); - let bounds = inset_rect(bounds, insets); + let bounds = inset_rect(bounds, insets, unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); let size: objc2_core_foundation::CGSize = unsafe { msg_send![super(self), cellSizeForBounds: bounds] }; padded_size(size, insets) @@ -127,7 +129,7 @@ define_class!( delegate: Option<&AnyObject>, event: Option<&NSEvent>, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), editWithFrame: frame, inView: view, editor: editor, delegate: delegate, event: event]; } @@ -141,7 +143,7 @@ define_class!( start: isize, length: isize, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), selectWithFrame: frame, inView: view, editor: editor, delegate: delegate, start: start, length: length]; } @@ -152,12 +154,13 @@ define_class!( } ); -fn inset_rect(rect: CGRect, insets: NSEdgeInsets) -> CGRect { - // AppKit's unflipped cell coordinates grow upward, so bottom moves origin.y. +fn inset_rect(rect: CGRect, insets: NSEdgeInsets, flipped: bool) -> CGRect { + // Native text fields and buttons are flipped: their top moves origin.y. + // Keep bottom-origin coordinates correct for an unflipped control view. CGRect::new( objc2_core_foundation::CGPoint::new( rect.origin.x + insets.left, - rect.origin.y + insets.bottom, + rect.origin.y + if flipped { insets.top } else { insets.bottom }, ), objc2_core_foundation::CGSize::new( (rect.size.width - insets.left - insets.right).max(0.0), @@ -200,9 +203,31 @@ pub(crate) fn install_secure_text_field_cell(field: &NSTextField, mtm: MainThrea } } +/// Install at label creation, before callers apply attributed text or styles. +/// Keep the factory label's text, font and line-breaking defaults. +pub(crate) fn install_label_cell(field: &NSTextField, mtm: MainThreadMarker) { + let value = field.attributedStringValue(); + let font = field.font(); + let original = field.cell().expect("label has a cell"); + install_text_field_cell(field, mtm); + field.setBezeled(false); + field.setBordered(false); + field.setEditable(false); + field.setSelectable(false); + field.setDrawsBackground(false); + field.setFont(font.as_deref()); + field.setAttributedStringValue(&value); + if let Some(cell) = field.cell() { + cell.setWraps(original.wraps()); + cell.setScrollable(original.isScrollable()); + cell.setUsesSingleLineMode(original.usesSingleLineMode()); + cell.setLineBreakMode(original.lineBreakMode()); + } +} + /// Apply padding to AppKit widgets with a native content-inset mechanism. -/// NSButton, NSTextField labels, and NSImageView do not expose one and remain -/// explicit no-ops until Perry gives those leaf widgets content wrappers. +/// Perry's labels and buttons include padding in their native sizing and +/// drawing paths, without replacing the widget or its target/action. pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, right: f64) { let insets = NSEdgeInsets { top, @@ -218,6 +243,11 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r } } + if AnyClass::get(c"NSButton").is_some_and(|cls| view.isKindOfClass(cls)) { + button::set_insets(view, insets); + return; + } + if let Some(cls) = AnyClass::get(c"NSTextField") { if view.isKindOfClass(cls) { let cell: *mut AnyObject = msg_send![view, cell]; @@ -227,10 +257,11 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r let responds: bool = msg_send![cell, respondsToSelector: selector]; if responds { let _: () = msg_send![cell, setPerryInsetsTop: top, left: left, bottom: bottom, right: right]; + let _: () = msg_send![view, invalidateIntrinsicContentSize]; let _: () = msg_send![view, setNeedsDisplay: true]; + return; } } - return; } } @@ -248,9 +279,15 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r // TextArea is registered as its enclosing NSScrollView, and // NSScrollView's four-sided contentInsets preserve asymmetry. let _: () = msg_send![view, setContentInsets: insets]; + return; } } } + #[cfg(debug_assertions)] + eprintln!( + "[perry/ui] setPadding is not supported for {}; place the widget in a padded stack", + view.class() + ); } #[cfg(test)] @@ -271,6 +308,7 @@ mod tests { bottom: 7.0, right: 14.0, }, + false, ); assert_eq!(got.origin.x, 10.0); assert_eq!(got.origin.y, 10.0); diff --git a/crates/perry-ui-macos/src/widgets/padding/button.rs b/crates/perry-ui-macos/src/widgets/padding/button.rs new file mode 100644 index 0000000000..0663ab5d11 --- /dev/null +++ b/crates/perry-ui-macos/src/widgets/padding/button.rs @@ -0,0 +1,129 @@ +//! Per-instance button padding, following the backend's drag/drop subclass +//! pattern. No cell or view is reconstructed: AppKit's factory state survives. +use super::{inset_rect, padded_size}; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject, Bool, ClassBuilder, Sel}; +use objc2::{msg_send, sel}; +use objc2_app_kit::{NSButton, NSButtonCell, NSView}; +use objc2_core_foundation::{CGRect, CGSize}; +use objc2_foundation::{NSEdgeInsets, NSValue}; +use std::ffi::CString; + +static INSETS_KEY: u8 = 0; +const CLASS_PREFIX: &[u8] = b"PerryButtonPadding_"; + +fn padding_class(view: &NSView) -> Option<&'static AnyClass> { + let mut cls = Some(view.class()); + while let Some(current) = cls { + if current.name().to_bytes().starts_with(CLASS_PREFIX) { + return Some(current); + } + cls = current.superclass(); + } + None +} + +pub(super) fn set_insets(view: &NSView, insets: NSEdgeInsets) { + unsafe { + if padding_class(view).is_none() { + let original = view.class(); + let name = CString::new(format!( + "PerryButtonPadding_{}", + original.name().to_string_lossy() + )) + .unwrap(); + let subclass = AnyClass::get(&name).unwrap_or_else(|| { + let mut builder = + ClassBuilder::new(&name, original).expect("padding subclass name"); + builder.add_method( + sel!(intrinsicContentSize), + intrinsic_size as unsafe extern "C-unwind" fn(*mut NSButton, Sel) -> CGSize, + ); + builder.add_method( + sel!(wantsUpdateLayer), + wants_update_layer as unsafe extern "C-unwind" fn(*mut NSButton, Sel) -> Bool, + ); + builder.add_method( + sel!(drawRect:), + draw_rect as unsafe extern "C-unwind" fn(*mut NSButton, Sel, CGRect), + ); + builder.register() + }); + // Main-thread-only NSView access; the subclass adds no ivars and + // all method signatures match NSButton. It also composes with + // drag/drop's dynamic subclasses, before or after this call. + assert_eq!(original.instance_size(), subclass.instance_size()); + let previous = AnyObject::set_class(view, subclass); + assert_eq!(previous, original); + } + // The association is owned by the native view, so it is released with + // the view and cannot leak or be reused for a different widget address. + let value = NSValue::new(insets); + objc2::ffi::objc_setAssociatedObject( + view as *const NSView as *mut AnyObject, + (&INSETS_KEY as *const u8).cast(), + Retained::as_ptr(&value) as *mut AnyObject, + objc2::ffi::OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ); + view.invalidateIntrinsicContentSize(); + view.setNeedsDisplay(true); + } +} + +unsafe fn insets(button: &NSButton) -> NSEdgeInsets { + let ptr = objc2::ffi::objc_getAssociatedObject( + button as *const NSButton as *const AnyObject, + (&INSETS_KEY as *const u8).cast(), + ); + if ptr.is_null() { + return NSEdgeInsets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + }; + } + // This private association key stores only NSValue. + (&*(ptr as *const NSValue)).get() +} + +fn superclass(button: &NSButton) -> &'static AnyClass { + padding_class(button) + .expect("padding method owner") + .superclass() + .unwrap() +} + +unsafe extern "C-unwind" fn intrinsic_size(button: *mut NSButton, _: Sel) -> CGSize { + let button = &*button; + let size = msg_send![super(button, superclass(button)), intrinsicContentSize]; + padded_size(size, insets(button)) +} + +unsafe extern "C-unwind" fn wants_update_layer(button: *mut NSButton, _: Sel) -> Bool { + let button = &*button; + let i = insets(button); + if i.top != 0.0 || i.left != 0.0 || i.bottom != 0.0 || i.right != 0.0 { + Bool::NO + } else { + msg_send![super(button, superclass(button)), wantsUpdateLayer] + } +} + +unsafe extern "C-unwind" fn draw_rect(button: *mut NSButton, _: Sel, dirty: CGRect) { + let button = &*button; + let i = insets(button); + if i.top == 0.0 && i.left == 0.0 && i.bottom == 0.0 && i.right == 0.0 { + let _: () = msg_send![super(button, superclass(button)), drawRect: dirty]; + return; + } + if let Some(cell) = button.cell() { + let cell = &*(Retained::as_ptr(&cell) as *const NSButtonCell); + let bounds = button.bounds(); + if button.isBordered() { + cell.drawBezelWithFrame_inView(bounds, button); + } + let content = inset_rect(bounds, i, button.isFlipped()); + cell.drawInteriorWithFrame_inView(content, button); + } +} diff --git a/crates/perry-ui-macos/src/widgets/text.rs b/crates/perry-ui-macos/src/widgets/text.rs index 99ec35bffb..893ba00375 100644 --- a/crates/perry-ui-macos/src/widgets/text.rs +++ b/crates/perry-ui-macos/src/widgets/text.rs @@ -16,6 +16,7 @@ pub fn create(text_ptr: *const u8) -> i64 { let ns_string = NSString::from_str(&text); let label = NSTextField::labelWithString(&ns_string, mtm); + super::padding::install_label_cell(&label, mtm); unsafe { let _: () = objc2::msg_send![&*label, setAccessibilityLabel: &*ns_string]; // Disable autoresizing mask so Auto Layout can size this view in NSStackView. diff --git a/crates/perry-ui-macos/tests/native_widget_padding.rs b/crates/perry-ui-macos/tests/native_widget_padding.rs new file mode 100644 index 0000000000..641802dac3 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_padding.rs @@ -0,0 +1,174 @@ +#[cfg(target_os = "macos")] +fn main() { + use objc2::msg_send; + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSButton, NSButtonCell, NSTextField, NSView}; + use objc2_core_foundation::CGSize; + use objc2_foundation::{MainThreadMarker, NSString}; + use perry_ui_macos::widgets; + + if std::env::args().any(|arg| arg == "--list") { + println!("native_widget_padding: test"); + return; + } + let mtm = MainThreadMarker::new().expect("native widget test runs on the main thread"); + let _app = NSApplication::sharedApplication(mtm); + let text = "Padding"; + let string = perry_runtime::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let label = widgets::text::create(string.cast()); + let button = widgets::button::create(string.cast(), 0.0); + let button_view = widgets::get_widget(button).unwrap(); + let native_button = unsafe { &*(Retained::as_ptr(&button_view) as *const NSButton) }; + let factory_button = unsafe { + NSButton::buttonWithTitle_target_action(&NSString::from_str(text), None, None, mtm) + }; + assert_eq!( + native_button.intrinsicContentSize(), + factory_button.intrinsicContentSize() + ); + assert_eq!(native_button.isBordered(), factory_button.isBordered()); + assert_eq!(native_button.bezelStyle(), factory_button.bezelStyle()); + assert_eq!(native_button.title(), factory_button.title()); + let cell = native_button.cell().unwrap(); + let factory_cell = factory_button.cell().unwrap(); + let cell = unsafe { &*(Retained::as_ptr(&cell) as *const NSButtonCell) }; + let factory_cell = unsafe { &*(Retained::as_ptr(&factory_cell) as *const NSButtonCell) }; + assert_eq!(cell.highlightsBy(), factory_cell.highlightsBy()); + assert_eq!(cell.showsStateBy(), factory_cell.showsStateBy()); + let target: *mut objc2::runtime::AnyObject = unsafe { msg_send![native_button, target] }; + let action = native_button.action(); + assert!(!target.is_null() && action.is_some()); + let bordered_size = native_button.intrinsicContentSize(); + widgets::set_edge_insets(button, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + native_button.intrinsicContentSize(), + CGSize::new(bordered_size.width + 16.0, bordered_size.height + 10.0) + ); + ink_bounds( + native_button, + native_button.intrinsicContentSize(), + "bordered-padded", + ); + widgets::set_edge_insets(button, 0.0, 0.0, 0.0, 0.0); + assert_eq!(native_button.intrinsicContentSize(), bordered_size); + assert_eq!(native_button.bezelStyle(), factory_button.bezelStyle()); + let label_view = widgets::get_widget(label).unwrap(); + let native_label = unsafe { &*(Retained::as_ptr(&label_view) as *const NSTextField) }; + let factory_label = NSTextField::labelWithString(&NSString::from_str(text), mtm); + assert_eq!( + native_label.intrinsicContentSize(), + factory_label.intrinsicContentSize() + ); + assert_eq!(native_label.stringValue(), factory_label.stringValue()); + assert_eq!(native_label.isEditable(), factory_label.isEditable()); + assert_eq!(native_label.isSelectable(), factory_label.isSelectable()); + assert_eq!( + native_label.drawsBackground(), + factory_label.drawsBackground() + ); + let attributed = widgets::attributed_text::create(); + widgets::attributed_text::append(attributed, string.cast(), 1, 0, 1, 18.0, 0.0, 0.0, 0.0, 1.0); + widgets::button::set_bordered(button, false); + let mut failures = Vec::new(); + for (name, handle, layer) in [ + ("label", label, false), + ("button", button, false), + ("label-layer", label, true), + ("button-layer", button, true), + ("attributed", attributed, true), + ] { + let view = widgets::get_widget(handle).unwrap(); + view.setWantsLayer(layer); + let before: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + let ink_before = ink_bounds(&view, before, &format!("{name}-before")); + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + let after: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + view.intrinsicContentSize(), + after, + "padding is replaced, never accumulated" + ); + let ink_after = ink_bounds(&view, after, &format!("{name}-after")); + println!("{name}: {before:?} -> {after:?}"); + println!( + "{name} ink: {ink_before:?} -> {ink_after:?}, flipped={}", + view.isFlipped() + ); + for (actual, expected) in [ + (ink_after.0 - ink_before.0, 5.0), + (ink_after.1 - ink_before.1, 3.0), + (ink_after.2 - ink_before.2, 5.0), + (ink_after.3 - ink_before.3, 3.0), + ] { + assert!( + (actual - expected).abs() < 0.51, + "{name}: content must move by top/left padding without being clipped" + ); + } + if (after.width - before.width - 16.0).abs() > 0.01 + || (after.height - before.height - 10.0).abs() > 0.01 + { + failures.push(name); + } + widgets::set_edge_insets(handle, 0.0, 0.0, 0.0, 0.0); + let reset: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + assert_eq!(reset, before, "resetting {name} padding restores its size"); + } + assert!(failures.is_empty(), "padding ignored by {failures:?}"); + let target_after: *mut objc2::runtime::AnyObject = unsafe { msg_send![native_button, target] }; + assert_eq!(target_after, target); + assert_eq!(native_button.action(), action); + drag_drop::check(mtm); + println!("PASS native widget padding"); + + fn ink_bounds(view: &NSView, size: CGSize, name: &str) -> (f64, f64, f64, f64) { + use objc2_app_kit::NSBitmapImageFileType; + use objc2_foundation::NSDictionary; + view.setFrameSize(CGSize::new(size.width.ceil(), size.height.ceil())); + let rect = view.bounds(); + let bitmap = view.bitmapImageRepForCachingDisplayInRect(rect).unwrap(); + view.cacheDisplayInRect_toBitmapImageRep(rect, &bitmap); + if let Ok(dir) = std::env::var("PERRY_PADDING_SNAPSHOTS") { + std::fs::create_dir_all(&dir).unwrap(); + let png = unsafe { + bitmap.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + } + .unwrap(); + std::fs::write( + std::path::Path::new(&dir).join(format!("{name}.png")), + png.to_vec(), + ) + .unwrap(); + } + let mut bounds = (isize::MAX, isize::MAX, -1, -1); + for y in 0..bitmap.pixelsHigh() { + for x in 0..bitmap.pixelsWide() { + if bitmap.colorAtX_y(x, y).unwrap().alphaComponent() > 0.1 { + bounds.0 = bounds.0.min(x); + bounds.1 = bounds.1.min(y); + bounds.2 = bounds.2.max(x); + bounds.3 = bounds.3.max(y); + } + } + } + assert!(bounds.2 >= 0, "{name}: rendered content must be visible"); + let scale_x = bitmap.pixelsWide() as f64 / rect.size.width; + let scale_y = bitmap.pixelsHigh() as f64 / rect.size.height; + ( + bounds.0 as f64 / scale_x, + bounds.1 as f64 / scale_y, + bounds.2 as f64 / scale_x, + bounds.3 as f64 / scale_y, + ) + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {} +#[cfg(target_os = "macos")] +#[path = "native_widget_padding/drag_drop.rs"] +mod drag_drop; diff --git a/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs b/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs new file mode 100644 index 0000000000..168196e74f --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs @@ -0,0 +1,67 @@ +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, ClassBuilder, Sel}; +use objc2::{msg_send, sel, ClassType}; +use objc2_app_kit::{NSButton, NSView}; +use objc2_foundation::{MainThreadMarker, NSString}; +use perry_ui_macos::{drag_drop, widgets}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CLICKS: AtomicUsize = AtomicUsize::new(0); + +extern "C-unwind" fn clicked(_: *mut AnyObject, _: Sel, _: *mut AnyObject) { + CLICKS.fetch_add(1, Ordering::Relaxed); +} + +pub(super) fn check(mtm: MainThreadMarker) { + unsafe { + // An observable native mouseDown endpoint, without opening a window + // or depending on a hardware event loop. The drop-only path must + // forward here, through either ordering of the two behaviors. + let mut builder = ClassBuilder::new(c"PaddingTestButton", NSButton::class()).unwrap(); + builder.add_method( + sel!(mouseDown:), + clicked as extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject), + ); + let class = builder.register(); + for padding_first in [true, false] { + let button = NSButton::buttonWithTitle_target_action( + &NSString::from_str("Drop"), + None, + None, + mtm, + ); + AnyObject::set_class(&button, class); + let view: Retained = Retained::cast_unchecked(button); + let handle = widgets::register_widget(view.clone()); + let before = view.intrinsicContentSize(); + if padding_first { + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + } + drag_drop::perry_ui_widget_on_drop(handle, 0.0); + if !padding_first { + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + } + let padded = view.intrinsicContentSize(); + assert_eq!(padded.width, before.width + 16.0); + assert_eq!(padded.height, before.height + 10.0); + let count = CLICKS.load(Ordering::Relaxed); + let _: () = msg_send![&*view, mouseDown: std::ptr::null::()]; + assert_eq!( + CLICKS.load(Ordering::Relaxed), + count + 1, + "padding_first={padding_first}" + ); + + let class_before = view.class(); + drag_drop::perry_ui_widget_on_drop(handle, 0.0); + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + view.class(), + class_before, + "repeated setters must not nest subclasses" + ); + let _: () = msg_send![&*view, mouseDown: std::ptr::null::()]; + assert_eq!(CLICKS.load(Ordering::Relaxed), count + 2); + } + } +} diff --git a/crates/perry/src/commands/attest.rs b/crates/perry/src/commands/attest.rs index 7a69aaae59..46a80c252f 100644 --- a/crates/perry/src/commands/attest.rs +++ b/crates/perry/src/commands/attest.rs @@ -124,13 +124,13 @@ pub fn build_attestation(binary_path: &Path, project_root: &Path) -> Result.attest.json` alongside the -/// binary. Returns the resolved sidecar path. +/// Write the manifest beside the binary, or beside its enclosing `.app` +/// bundle. Returns the resolved sidecar path. pub fn write_attestation( binary_path: &Path, manifest: &AttestationManifest, ) -> Result { - let out = binary_path.with_extension("attest.json"); + let out = super::sidecar::path_for_binary(binary_path, "attest.json"); let body = serde_json::to_string_pretty(manifest) .context("failed to serialize attestation manifest")?; std::fs::write(&out, body).with_context(|| format!("failed to write {}", out.display()))?; @@ -142,7 +142,7 @@ pub fn write_attestation( /// manifest on success; bails with an actionable diagnostic on /// mismatch or missing sidecar. pub fn verify_against_sidecar(binary_path: &Path) -> Result { - let sidecar = binary_path.with_extension("attest.json"); + let sidecar = super::sidecar::path_for_binary(binary_path, "attest.json"); if !sidecar.exists() { bail!( "no attestation sidecar at {}.\n\ @@ -232,6 +232,25 @@ mod tests { assert_eq!(read_back, m); } + #[test] + fn bundle_attestation_lives_outside_the_seal_and_verifies_the_inner_binary() { + let dir = tempfile::tempdir().unwrap(); + let app = dir.path().join("My App.v2.app"); + let binary = app.join("Contents/MacOS/Engine"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, b"signed executable").unwrap(); + let manifest = build_attestation(&binary, dir.path()).unwrap(); + let written = write_attestation(&binary, &manifest).unwrap(); + assert_eq!(written, dir.path().join("My App.v2.app.attest.json")); + assert!(!binary.with_extension("attest.json").exists()); + assert_eq!(verify_against_sidecar(&binary).unwrap(), manifest); + std::fs::write(&binary, b"modified executable").unwrap(); + assert!(verify_against_sidecar(&binary) + .unwrap_err() + .to_string() + .contains("MISMATCH")); + } + #[test] fn verify_fails_when_binary_tampered() { let (dir, path) = temp_bin(b"original"); diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 4b10f5024f..91d91ae29a 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -20,6 +20,7 @@ mod bootstrap; mod build_cache; mod bundle_apple; mod bundle_ios; +mod bundle_macos; mod defines; // `pub(crate)` so `commands::deps` can reuse `cjs_wrap::detect`'s // comment/string masker for its source scans (D005) instead of duplicating a diff --git a/crates/perry/src/commands/compile/bundle_macos.rs b/crates/perry/src/commands/compile/bundle_macos.rs new file mode 100644 index 0000000000..69430d339b --- /dev/null +++ b/crates/perry/src/commands/compile/bundle_macos.rs @@ -0,0 +1,211 @@ +//! Local macOS UI application packaging (#10078). + +use anyhow::{anyhow, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::bundle_apple::{read_app_display_name, xml_escape}; +use super::CompilationContext; +use crate::OutputFormat; + +pub(super) struct MacosBundleLayout { + pub app_dir: PathBuf, + pub executable: PathBuf, +} + +pub(super) fn layout_for_compile( + needs_ui: bool, + output_type: &str, + target: Option<&str>, + output: &Path, +) -> Result> { + let macos = target == Some("macos") || (target.is_none() && cfg!(target_os = "macos")); + if !needs_ui || output_type != "executable" || !macos { + return Ok(None); + } + let explicit_bundle = output.extension().is_some_and(|ext| ext == "app"); + let executable_name = if explicit_bundle { + output.file_stem() + } else { + output.file_name() + } + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("macOS app output needs a filename: {}", output.display()))?; + let app_dir = if explicit_bundle { + output.to_path_buf() + } else { + let mut name = executable_name.to_os_string(); + name.push(".app"); + output.with_file_name(name) + }; + let executable = app_dir.join("Contents/MacOS").join(executable_name); + Ok(Some(MacosBundleLayout { + app_dir, + executable, + })) +} + +/// The embedded and bundle plists must agree on executable and app identity. +pub(super) fn info_plist(ctx: &CompilationContext, input: &Path, executable: &Path) -> String { + let filename = executable.file_name().unwrap_or_default().to_string_lossy(); + let display_name = + read_app_display_name(input, "macos").unwrap_or_else(|| filename.to_string()); + format!( + r#" + + + + CFBundleInfoDictionaryVersion6.0 + CFBundleIdentifier{bundle_id} + CFBundleName{display_name} + CFBundleDisplayName{display_name} + CFBundleExecutable{filename} + CFBundlePackageTypeAPPL + CFBundleShortVersionString{version} + CFBundleVersion{build_number} + NSHighResolutionCapable + NSCameraUsageDescription + This app uses the camera for WebView video calls. + NSMicrophoneUsageDescription + This app uses the microphone for WebView video calls. + + +"#, + bundle_id = xml_escape(&ctx.app_metadata.bundle_id), + display_name = xml_escape(&display_name), + filename = xml_escape(&filename), + version = xml_escape(&ctx.app_metadata.version), + build_number = ctx.app_metadata.build_number, + ) +} + +fn write_bundle_files(layout: &MacosBundleLayout, linked_exe: &Path, plist: &str) -> Result<()> { + fs::create_dir_all(layout.executable.parent().unwrap())?; + fs::create_dir_all(layout.app_dir.join("Contents/Resources"))?; + if linked_exe != layout.executable { + fs::copy(linked_exe, &layout.executable) + .with_context(|| format!("copy executable into {}", layout.app_dir.display()))?; + } + fs::write(layout.app_dir.join("Contents/Info.plist"), plist)?; + Ok(()) +} + +pub(super) fn bundle_for_macos( + layout: &MacosBundleLayout, + linked_exe: &Path, + input: &Path, + ctx: &CompilationContext, + target: Option<&str>, + i18n_table: Option<&perry_transform::i18n::I18nStringTable>, + i18n_config: Option<&perry_transform::i18n::I18nConfig>, + format: OutputFormat, +) -> Result<(PathBuf, String)> { + write_bundle_files( + layout, + linked_exe, + &info_plist(ctx, input, &layout.executable), + )?; + if linked_exe != layout.executable { + if let Some(parent) = linked_exe.parent() { + super::resources::copy_standalone_resource_dirs(input, parent); + super::resources::stage_native_library_artifacts(ctx, parent, format)?; + } + } + let resources = layout.app_dir.join("Contents/Resources"); + super::resources::copy_standalone_resource_dirs(input, &resources); + super::resources::stage_native_library_artifacts(ctx, &resources, format)?; + super::i18n_emit::write_lproj_localized_strings(&resources, i18n_table, i18n_config); + super::native_addon_sidecar::stage_native_addon_sidecar(ctx, &layout.executable, target)?; + + super::post_link::emit_sandbox_sidecar(ctx, &layout.executable, format); + + // Local development needs no signing identity or provisioning profile. + // Seal the external plist and copied resources after the bundle is complete. + if cfg!(target_os = "macos") { + let signed = Command::new("codesign") + .args(["--force", "--sign", "-", "--timestamp=none"]) + .arg(&layout.app_dir) + .output() + .context("sign local macOS app bundle")?; + if !signed.status.success() { + anyhow::bail!( + "codesign failed for {}: {}", + layout.app_dir.display(), + String::from_utf8_lossy(&signed.stderr) + ); + } + } + let bundle_id = ctx.app_metadata.bundle_id.clone(); + match format { + OutputFormat::Text => println!("Wrote macOS app bundle: {}", layout.app_dir.display()), + OutputFormat::Json => println!( + "{}", + serde_json::json!({"success": true, "output": layout.app_dir, "bundle_id": bundle_id}) + ), + } + Ok((layout.app_dir.clone(), bundle_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_macos_ui_executables_are_bundled() { + let output = Path::new("demo"); + for (ui, kind, target) in [ + (false, "executable", "macos"), + (true, "dylib", "macos"), + (true, "staticlib", "macos"), + (true, "executable", "linux"), + (true, "executable", "ios-simulator"), + ] { + assert!(layout_for_compile(ui, kind, Some(target), output) + .unwrap() + .is_none()); + } + assert!( + layout_for_compile(true, "executable", Some("macos"), output) + .unwrap() + .is_some() + ); + } + + #[test] + fn bundle_paths_preserve_names_and_explicit_app_extension() { + for (output, expected) in [ + ("out/My App.v2", "out/My App.v2.app"), + ("out/My App.v2.app", "out/My App.v2.app"), + ] { + let layout = layout_for_compile(true, "executable", Some("macos"), Path::new(output)) + .unwrap() + .unwrap(); + assert_eq!(layout.app_dir, Path::new(expected)); + assert_eq!( + layout.executable, + Path::new(expected).join("Contents/MacOS/My App.v2") + ); + } + } + + #[test] + fn packaging_keeps_linked_binary_and_does_not_truncate_an_in_bundle_output() { + let dir = tempfile::tempdir().unwrap(); + let raw = dir.path().join("demo"); + fs::write(&raw, b"linked executable").unwrap(); + let layout = layout_for_compile(true, "executable", Some("macos"), &raw) + .unwrap() + .unwrap(); + write_bundle_files(&layout, &raw, "first plist").unwrap(); + assert_eq!(fs::read(&raw).unwrap(), b"linked executable"); + assert_eq!(fs::read(&layout.executable).unwrap(), b"linked executable"); + assert!(layout.app_dir.join("Contents/Resources").is_dir()); + write_bundle_files(&layout, &layout.executable, "updated plist").unwrap(); + assert_eq!(fs::read(&layout.executable).unwrap(), b"linked executable"); + assert_eq!( + fs::read_to_string(layout.app_dir.join("Contents/Info.plist")).unwrap(), + "updated plist" + ); + } +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 67c7490997..026683081f 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -47,6 +47,7 @@ mod static_require_transform; mod tests; mod walk; mod wasm_asset; +mod worker; use binding_faithfulness::audit_native_binding_choice; pub(super) use discovery::is_nextjs_runtime_module; @@ -1008,7 +1009,7 @@ fn collect_module_one( )); return; } - if set.len() != 1 { + if eval_mode && set.len() != 1 { dyn_errors.push(format!( "worker_threads Worker in module {}: filename must resolve to exactly one path for now, got {}", module_name, @@ -1034,28 +1035,28 @@ fn collect_module_one( return; } } - } else if set[0].starts_with("file:") { - // Helper-returned URLs carry a URL spelling, while the - // module resolver (including --bunfs-root) consumes a - // filesystem spelling. Decode through the URL parser - // before recording both the import edge and Worker path. - match url::Url::parse(&set[0]) - .ok() - .and_then(|url| url.to_file_path().ok()) - { - Some(path) => set[0] = path.to_string_lossy().into_owned(), - None => { - dyn_errors.push(format!( - "worker_threads Worker in module {}: invalid file URL {:?}", - module_name, set[0] - )); + } + let imports = if eval_mode { + set.clone() + } else { + match worker::resolve_candidates( + &mut set, + entry_path, + &canonical, + &module_name, + ctx, + format, + ) { + Ok(imports) => imports, + Err(error) => { + dyn_errors.push(error); return; } } - } - for p in &set { - if !new_dyn_imports.contains(p) { - new_dyn_imports.push(p.clone()); + }; + for path in imports { + if !new_dyn_imports.contains(&path) { + new_dyn_imports.push(path); } } worker_path_sets.push(set); diff --git a/crates/perry/src/commands/compile/collect_modules/worker.rs b/crates/perry/src/commands/compile/collect_modules/worker.rs new file mode 100644 index 0000000000..6fd030050b --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/worker.rs @@ -0,0 +1,63 @@ +//! Resolve conservative Worker return unions before adding module edges. +use std::collections::HashSet; +use std::path::Path; + +use super::import_helpers::cached_resolve_import_with_lexical_base; +use super::{CompilationContext, OutputFormat}; + +pub(super) fn resolve_candidates( + paths: &mut Vec, + entry_path: &Path, + canonical: &Path, + module_name: &str, + ctx: &mut CompilationContext, + format: OutputFormat, +) -> Result, String> { + let mut imports = Vec::new(); + let mut entries = HashSet::new(); + for path in paths.iter_mut() { + if path.starts_with("file:") { + *path = url::Url::parse(path) + .ok() + .and_then(|url| url.to_file_path().ok()) + .ok_or_else(|| { + format!( + "worker_threads Worker in module {module_name}: invalid file URL {path:?}" + ) + })? + .to_string_lossy() + .into_owned(); + } + if let Some(resolved) = + cached_resolve_import_with_lexical_base(path, entry_path, canonical, ctx) + { + // Keep spelling aliases for runtime dispatch. Module discovery + // deduplicates these edges by canonical path, so each entry is + // compiled only once (e.g. a --define and a relative URL fallback). + if !imports.contains(path) { + imports.push(path.clone()); + } + if entries.insert(resolved.canonical_path.clone()) + && matches!(format, OutputFormat::Text) + { + eprintln!(" Worker entry: {}", resolved.canonical_path.display()); + } + } else if matches!(format, OutputFormat::Text) { + eprintln!( + " Warning: worker_threads Worker in module {module_name}: skipping candidate {path:?}: file not found" + ); + } + } + // Missing candidates never become import edges. Retain their spellings + // alongside valid ones so codegen cannot mistake a partial union for a + // proven single target: choosing a missing candidate must throw at runtime. + if imports.is_empty() { + paths.clear(); + if matches!(format, OutputFormat::Text) { + eprintln!( + " Warning: worker_threads Worker in module {module_name}: no existing candidates — this Worker will throw if constructed at runtime" + ); + } + } + Ok(imports) +} 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 832542864a..7726c22339 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -1793,8 +1793,8 @@ pub(crate) fn build_and_run_link( // macOS privacy APIs (including camera/microphone requests made by // WKWebView) consult the process Info.plist for usage-description keys. - // Perry's direct desktop output is a Mach-O executable, not a .app bundle, - // so embed a minimal Info.plist section when linking native macOS UI apps. + // Keep the linked binary's embedded metadata identical to its macOS app + // bundle metadata; the standalone executable remains available as well. // Without this, WKWebView media capture can be denied by the platform even // when WKUIDelegate grants the web-origin permission. let is_macos_executable = @@ -1805,35 +1805,7 @@ pub(crate) fn build_and_run_link( .file_stem() .and_then(|s| s.to_str()) .unwrap_or("perry-app"); - let bundle_id = format!( - "dev.perry.{}", - exe_stem - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - ); - let info_plist = format!( - r#" - - - - CFBundleIdentifier - {bundle_id} - CFBundleName - {exe_stem} - CFBundleExecutable - {exe_stem} - CFBundlePackageType - APPL - NSCameraUsageDescription - This app uses the camera for WebView video calls. - NSMicrophoneUsageDescription - This app uses the microphone for WebView video calls. - - -"# - ); + let info_plist = super::super::bundle_macos::info_plist(ctx, args_input, exe_path); let plist_path = std::env::temp_dir().join(format!( "perry-embedded-info-{}-{}.plist", std::process::id(), diff --git a/crates/perry/src/commands/compile/post_link.rs b/crates/perry/src/commands/compile/post_link.rs index 8894ba27b8..1a0137c116 100644 --- a/crates/perry/src/commands/compile/post_link.rs +++ b/crates/perry/src/commands/compile/post_link.rs @@ -69,6 +69,49 @@ pub(super) fn strip_final_binary( } } +/// Emit the optional profile before an app bundle is signed. +pub(super) fn emit_sandbox_sidecar( + ctx: &CompilationContext, + exe_path: &Path, + format: OutputFormat, +) { + // #506 — emit `.sandbox` next to the binary when + // `--emit-sandbox` (or the equivalent env / package.json + // knob) is set. macOS only for the MVP; other platforms + // log a once-per-build note that the kernel-sandbox MVP + // is macOS-only and the matching seccomp / AppContainer / + // ... support lands as #506 follow-up. + if ctx.emit_sandbox { + #[cfg(target_os = "macos")] + { + match super::super::sandbox_profile::emit_macos_sandbox_profile(ctx, exe_path) { + Ok(path) => match format { + OutputFormat::Text => { + println!("Wrote sandbox profile: {}", path.display()) + } + OutputFormat::Json => {} + }, + Err(e) => match format { + OutputFormat::Text => { + eprintln!("warning: failed to emit sandbox profile: {}", e); + } + OutputFormat::Json => {} + }, + } + } + #[cfg(not(target_os = "macos"))] + { + // The profile is written next to the binary only on macOS. + let _ = exe_path; + if let OutputFormat::Text = format { + eprintln!( + "note: `--emit-sandbox` is macOS-only in this MVP; Linux seccomp + Windows AppContainer support tracked under #506." + ); + } + } + } +} + /// #504: emit `.attest.json` AFTER strip/codesign so the /// captured SHA-256 matches what users will actually download. /// Best-effort — errors log and continue. diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f57c5a8f96..e5164e2534 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2893,6 +2893,12 @@ pub fn run_with_parse_cache( HashMap::new(); for (path, hir_module) in &ctx.native_modules { let mut local_map: HashMap = HashMap::new(); + let mut worker_paths = HashSet::new(); + perry_hir::for_each_worker_new(hir_module, &mut |expr| { + if let perry_hir::Expr::WorkerNew { paths, .. } = expr { + worker_paths.extend(paths.iter().cloned()); + } + }); for import in &hir_module.imports { if !(import.is_dynamic || import.is_dynamic_target) { continue; @@ -2939,6 +2945,25 @@ pub fn run_with_parse_cache( None => continue, }; let target_prefix = sanitize_module_name(&target_name); + if worker_paths.contains(&import.source) { + // Preserve the lexical URL spelling, including .js -> .ts + // resolution and Bun virtual roots. Canonicalizing here would + // lose the href produced by new URL(path, import.meta.url). + let file_url = if Path::new(&import.source).is_absolute() { + url::Url::from_file_path(&import.source).ok() + } else { + url::Url::from_file_path(path) + .ok() + .and_then(|base| base.join(&import.source).ok()) + }; + if let Some(url) = file_url { + if let Ok(path) = url.to_file_path() { + local_map + .insert(path.to_string_lossy().into_owned(), target_prefix.clone()); + } + local_map.insert(url.to_string(), target_prefix.clone()); + } + } local_map.insert(import.source.clone(), target_prefix); } if !local_map.is_empty() { @@ -6458,6 +6483,23 @@ pub fn run_with_parse_cache( // file this link is about to write (#5740). None => output_path::default_output_path(is_dylib, is_staticlib, target.as_deref(), stem), }; + let macos_bundle_layout = bundle_macos::layout_for_compile( + ctx.needs_ui, + &args.output_type, + target.as_deref(), + &exe_path, + )?; + // An explicit `-o Name.app` is a directory destination. Other output + // names keep the linked binary and also produce the sibling app bundle. + let exe_path = if let Some(layout) = macos_bundle_layout + .as_ref() + .filter(|l| l.app_dir == exe_path) + { + fs::create_dir_all(layout.executable.parent().unwrap())?; + layout.executable.clone() + } else { + exe_path + }; if !failed_modules.is_empty() { // The loud failure summary + abort already ran earlier (right @@ -7317,7 +7359,23 @@ pub fn run_with_parse_cache( } } - // Track iOS bundle info for CompileResult + // Finish editing the executable before copying/sealing an app bundle. + // The link-cache fingerprint is written below, after bundle signing. + if link_cache_status.stats().linked { + strip_final_binary( + &ctx, + &exe_path, + target.as_deref(), + is_dylib, + is_ios, + is_visionos, + is_tvos, + is_watchos, + is_harmonyos, + ); + } + + // Track Apple bundle info for CompileResult let mut result_bundle_id: Option = None; let mut result_app_dir: Option = None; @@ -7374,6 +7432,22 @@ pub fn run_with_parse_cache( )?; result_bundle_id = Some(bundle_id); result_app_dir = Some(app_dir); + } else if let Some(layout) = macos_bundle_layout.as_ref() { + if exe_path != layout.executable { + post_link::emit_sandbox_sidecar(&ctx, &exe_path, format); + } + let (app_dir, bundle_id) = bundle_macos::bundle_for_macos( + layout, + &exe_path, + &args.input, + &ctx, + target.as_deref(), + i18n_table.as_ref(), + i18n_config.as_ref(), + format, + )?; + result_bundle_id = Some(bundle_id); + result_app_dir = Some(app_dir); } else { // For Windows/Linux (non-bundle targets), copy asset directories next to the exe // so that resolve_asset_path can find them relative to the executable. @@ -7426,39 +7500,7 @@ pub fn run_with_parse_cache( } } - // #506 — emit `.sandbox` next to the binary when - // `--emit-sandbox` (or the equivalent env / package.json - // knob) is set. macOS only for the MVP; other platforms - // log a once-per-build note that the kernel-sandbox MVP - // is macOS-only and the matching seccomp / AppContainer / - // ... support lands as #506 follow-up. - if ctx.emit_sandbox { - #[cfg(target_os = "macos")] - { - match super::super::sandbox_profile::emit_macos_sandbox_profile(&ctx, &exe_path) { - Ok(path) => match format { - OutputFormat::Text => { - println!("Wrote sandbox profile: {}", path.display()) - } - OutputFormat::Json => {} - }, - Err(e) => match format { - OutputFormat::Text => { - eprintln!("warning: failed to emit sandbox profile: {}", e); - } - OutputFormat::Json => {} - }, - } - } - #[cfg(not(target_os = "macos"))] - { - if let OutputFormat::Text = format { - eprintln!( - "note: `--emit-sandbox` is macOS-only in this MVP; Linux seccomp + Windows AppContainer support tracked under #506." - ); - } - } - } + post_link::emit_sandbox_sidecar(&ctx, &exe_path, format); } emit_android_i18n_resources( @@ -7470,17 +7512,6 @@ pub fn run_with_parse_cache( ); if link_cache_status.stats().linked { - strip_final_binary( - &ctx, - &exe_path, - target.as_deref(), - is_dylib, - is_ios, - is_visionos, - is_tvos, - is_watchos, - is_harmonyos, - ); write_link_cache_manifest(&link_cache_status, &exe_path); } @@ -7506,6 +7537,11 @@ pub fn run_with_parse_cache( ); emit_attestation_sidecar(&ctx, &exe_path, format); + if let Some(layout) = macos_bundle_layout.as_ref() { + if exe_path != layout.executable { + emit_attestation_sidecar(&ctx, &layout.executable, format); + } + } print_binary_size(format, &exe_path); diff --git a/crates/perry/src/commands/mod.rs b/crates/perry/src/commands/mod.rs index e1c0e75366..927e3ee65d 100644 --- a/crates/perry/src/commands/mod.rs +++ b/crates/perry/src/commands/mod.rs @@ -38,6 +38,7 @@ pub mod run; pub mod sandbox_profile; pub mod sanitize; pub mod setup; +mod sidecar; pub mod stdlib_features; pub mod typecheck; pub mod types; diff --git a/crates/perry/src/commands/run/launch.rs b/crates/perry/src/commands/run/launch.rs index 127b6904d7..a8c7178ba5 100644 --- a/crates/perry/src/commands/run/launch.rs +++ b/crates/perry/src/commands/run/launch.rs @@ -99,7 +99,10 @@ pub fn launch_native(exe_path: &Path, program_args: &[String], format: OutputFor println!(); } - let status = Command::new(&exe) + // Execute inside the bundle so Foundation and AppKit see its application + // identity. Direct execution preserves argv, terminal I/O, and exit status. + let executable = native_executable_path(&exe)?; + let status = Command::new(&executable) .args(program_args) .status() .map_err(|e| anyhow!("Failed to launch {}: {}", exe.display(), e))?; @@ -110,6 +113,113 @@ pub fn launch_native(exe_path: &Path, program_args: &[String], format: OutputFor Ok(()) } +fn native_executable_path(output: &Path) -> Result { + if !cfg!(target_os = "macos") + || !output.is_dir() + || output.extension().is_none_or(|ext| ext != "app") + { + return Ok(output.to_path_buf()); + } + let plist = output.join("Contents/Info.plist"); + let result = Command::new("/usr/bin/plutil") + .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"]) + .arg(&plist) + .output() + .with_context(|| format!("read application executable from {}", plist.display()))?; + if !result.status.success() { + bail!("Cannot read CFBundleExecutable from {}", plist.display()); + } + let value = + String::from_utf8(result.stdout).context("application executable name is not UTF-8")?; + let name = value.strip_suffix('\n').unwrap_or(&value); + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(std::path::Component::Normal(_))) + || components.next().is_some() + { + bail!("Invalid CFBundleExecutable in {}", plist.display()); + } + let executable = output.join("Contents/MacOS").join(name); + if !executable.is_file() { + bail!("Application executable not found: {}", executable.display()); + } + Ok(executable) +} + +#[cfg(all(test, target_os = "macos"))] +mod macos_bundle_tests { + use super::*; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::Stdio; + + #[test] + fn launch_uses_plist_executable_even_after_the_bundle_is_renamed() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Renamed Application.app"); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + let binary = bundle.join("Contents/MacOS/Original Engine"); + std::fs::write(&binary, "executable witness").unwrap(); + std::fs::write( + bundle.join("Contents/Info.plist"), + r#" + CFBundleExecutableOriginal Engine + "#, + ) + .unwrap(); + assert_eq!(native_executable_path(&bundle).unwrap(), binary); + assert_eq!(native_executable_path(&binary).unwrap(), binary); + std::fs::remove_file(&binary).unwrap(); + assert!(native_executable_path(&bundle).is_err()); + } + + #[test] + fn launch_bundle_preserves_arguments_terminal_streams_and_exit_status() { + const CHILD: &str = "PERRY_TEST_BUNDLE_LAUNCH_CHILD"; + if let Some(bundle) = std::env::var_os(CHILD) { + launch_native( + Path::new(&bundle), + &["argument with spaces".into()], + OutputFormat::Text, + ) + .unwrap(); + unreachable!("the launched witness exits with status 7"); + } + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Terminal Witness.app"); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + let binary = bundle.join("Contents/MacOS/engine"); + std::fs::write(&binary, "#!/bin/sh\nread -r line\nprintf 'argv=%s input=%s\\n' \"$1\" \"$line\"\nprintf 'stderr witness\\n' >&2\nexit 7\n").unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bundle.join("Contents/Info.plist"), + "CFBundleExecutableengine").unwrap(); + let thread = std::thread::current(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + thread.name().unwrap(), + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, &bundle) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"input witness\n") + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert!(String::from_utf8_lossy(&output.stdout) + .contains("argv=argument with spaces input=input witness\n")); + assert!(String::from_utf8_lossy(&output.stderr).contains("stderr witness\n")); + } +} + /// Launch on iOS Simulator: install + launch pub fn launch_ios_simulator( app_dir: &Path, diff --git a/crates/perry/src/commands/sandbox_profile.rs b/crates/perry/src/commands/sandbox_profile.rs index da358af62c..024d94a045 100644 --- a/crates/perry/src/commands/sandbox_profile.rs +++ b/crates/perry/src/commands/sandbox_profile.rs @@ -42,7 +42,7 @@ pub fn emit_macos_sandbox_profile( ctx: &CompilationContext, binary_path: &Path, ) -> std::io::Result { - let out = binary_path.with_extension("sandbox"); + let out = super::sidecar::path_for_binary(binary_path, "sandbox"); let body = build_macos_profile(ctx); let mut f = std::fs::File::create(&out)?; f.write_all(body.as_bytes())?; diff --git a/crates/perry/src/commands/sidecar.rs b/crates/perry/src/commands/sidecar.rs new file mode 100644 index 0000000000..89a3bc46f2 --- /dev/null +++ b/crates/perry/src/commands/sidecar.rs @@ -0,0 +1,21 @@ +//! Paths for files distributed alongside compiled executables. + +use std::path::{Path, PathBuf}; + +// Keep non-code sidecars out of Contents/MacOS and outside the bundle seal. +// Attestations are written after signing; sandbox profiles can be customized. +pub(super) fn path_for_binary(binary_path: &Path, extension: &str) -> PathBuf { + if let Some(macos) = binary_path.parent() { + if let Some(contents) = macos.parent() { + if let Some(app) = contents.parent() { + if macos.file_name().is_some_and(|name| name == "MacOS") + && contents.file_name().is_some_and(|name| name == "Contents") + && app.extension().is_some_and(|ext| ext == "app") + { + return app.with_extension(format!("app.{extension}")); + } + } + } + } + binary_path.with_extension(extension) +} diff --git a/crates/perry/tests/issue_10236_worker_path_await_helper.rs b/crates/perry/tests/issue_10236_worker_path_await_helper.rs new file mode 100644 index 0000000000..f041c7e643 --- /dev/null +++ b/crates/perry/tests/issue_10236_worker_path_await_helper.rs @@ -0,0 +1,170 @@ +//! OpenCode's awaited TUI worker selector discovers a union of native entries. +use std::path::Path; +use std::process::{Command, Output}; + +fn diagnostics(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn compile(root: &Path, source: &str) -> String { + std::fs::write(root.join("main.ts"), source).unwrap(); + let worker = root.join("worker.ts"); + let define = format!( + "WORKER_PATH={}", + serde_json::to_string(&worker.to_string_lossy()).unwrap() + ); + let output = Command::new(env!("CARGO_BIN_EXE_perry")) + .current_dir(root) + .args([ + "compile", + "main.ts", + "-o", + "app", + "--platform", + "bun", + "--define", + ]) + .arg(define) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_NO_CACHE", "1") + .output() + .unwrap(); + let log = diagnostics(&output); + assert!(output.status.success(), "{log}"); + log +} + +fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("worker.ts"), "postMessage('ready');").unwrap(); + dir +} + +fn run(root: &Path, args: &[&str]) -> String { + let output = Command::new(root.join("app")) + .current_dir(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + output.status, + diagnostics(&output) + ); + String::from_utf8(output.stdout) + .unwrap() + .replace("\r\n", "\n") +} + +const START: &str = r#" +const worker = new Worker(file, { env: { WORKER_TEST: '10236' } }); +worker.onmessage = ({ data }) => { + console.log('reply', data); + worker.terminate().then(() => process.exit(0)); +}; +setTimeout(() => process.exit(2), 5000); +"#; + +#[test] +fn opencode_awaited_if_return_helper_skips_missing_and_deduplicates_entry() { + let dir = fixture(); + let source = format!( + r#" +import {{ existsSync as exists }} from 'node:fs'; +import {{ fileURLToPath }} from 'node:url'; +declare const WORKER_PATH: string; +async function target() {{ + if (typeof WORKER_PATH !== 'undefined') return WORKER_PATH; + const dist = new URL('../x/worker.js', import.meta.url); + if (await exists(fileURLToPath(dist))) return dist; + return new URL('./worker.ts', import.meta.url); +}} +const file = await target(); +{START} +"# + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("Worker path helper:"), "{log}"); + assert!(!log.contains("this Worker will throw"), "{log}"); + assert!( + log.contains("skipping candidate") && log.contains("../x/worker.js"), + "{log}" + ); + // Discovery can run twice when platform dependencies trigger recollection. + // The graph summary must still contain only main plus one worker. + assert!(log.contains("Found 2 module(s): 2 native"), "{log}"); + assert_eq!(run(dir.path(), &[]), "reply ready\n"); +} + +#[test] +fn sync_two_returns_dispatches_both_existing_entries_and_preserves_effects() { + let dir = fixture(); + std::fs::write(dir.path().join("other space.ts"), "postMessage('other');").unwrap(); + let source = format!( + r#" +function choose() {{ console.log('choose'); return process.argv.includes('--other'); }} +function target() {{ + if (choose()) return new URL('./other space.ts', import.meta.url); + return './worker.ts'; +}} +const file = target(); +{START} +"# + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("this Worker will throw"), "{log}"); + assert!(log.contains("Found 3 module(s): 3 native"), "{log}"); + assert_eq!(run(dir.path(), &[]), "choose\nreply ready\n"); + assert_eq!(run(dir.path(), &["--other"]), "choose\nreply other\n"); +} + +#[test] +fn await_literal_returning_async_helper() { + let dir = fixture(); + let source = format!( + "async function target() {{ return './worker.ts'; }} const file = await target(); {START}" + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("Worker path helper:"), "{log}"); + assert_eq!(run(dir.path(), &[]), "reply ready\n"); +} + +#[test] +fn missing_runtime_selection_throws_instead_of_starting_another_candidate() { + let dir = fixture(); + let source = r#" +function target() { + if (process.argv.includes('--missing')) return './missing.ts'; + return './worker.ts'; +} +try { + new Worker(target()); + console.log('unexpected worker'); + process.exit(2); +} catch (error) { console.log('caught', error.message); } +"#; + let log = compile(dir.path(), source); + assert!(log.contains("skipping candidate"), "{log}"); + assert!(run(dir.path(), &["--missing"]) + .contains("did not match an existing compile-time-resolved worker entry")); +} + +#[test] +fn opaque_return_and_recursive_helper_keep_existing_warnings() { + for (helper, warning) in [ + ("async function target() { if (process.argv.length) return './worker.ts'; return opaque(); }", "opaque call target"), + ("async function target() { if (process.argv.length) return './worker.ts'; return await target(); }", "recursive helper call"), + ] { + let dir = fixture(); + let source = format!("const opaque: any = process.argv[0]; {helper} async function cold() {{ const file = await target(); new Worker(file); }} console.log('cold');"); + let log = compile(dir.path(), &source); + assert!(log.contains("Worker path helper:") && log.contains(warning), "{log}"); + assert!(log.contains("this Worker will throw"), "{log}"); + assert_eq!(run(dir.path(), &[]), "cold\n"); + } +} diff --git a/crates/perry/tests/loop_lexical_tdz.rs b/crates/perry/tests/loop_lexical_tdz.rs new file mode 100644 index 0000000000..8b7c812d96 --- /dev/null +++ b/crates/perry/tests/loop_lexical_tdz.rs @@ -0,0 +1,20 @@ +//! CI-visible entry point for the bounded Node/native regression. +use std::{path::Path, process::Command}; + +#[test] +fn standalone_regression() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("node") + .arg(root.join("scripts/test-loop-lexical-tdz.mjs")) + .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) + .env("PERRY_WORKSPACE_ROOT", &root) + .current_dir(&root) + .output() + .expect("run bounded Node regression driver"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/perry/tests/macos_app_bundle.rs b/crates/perry/tests/macos_app_bundle.rs new file mode 100644 index 0000000000..a07ff1fc4a --- /dev/null +++ b/crates/perry/tests/macos_app_bundle.rs @@ -0,0 +1,191 @@ +//! #10078: local macOS UI builds need a real application bundle. This exercises +//! linking, packaging, and signing without launching a window in the test runner. +#![cfg(target_os = "macos")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn checked(mut command: Command) -> Output { + let output = command.output().expect("run command"); + assert!( + output.status.success(), + "{command:?}: {}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn runtime_dir() -> PathBuf { + let archives = [ + "libperry_runtime.a", + "libperry_stdlib.a", + "libperry_ext_net.a", + "libperry_ui_macos.a", + ]; + if let Some(dir) = std::env::var_os("PERRY_RUNTIME_DIR") { + let dir = PathBuf::from(dir); + if archives.iter().all(|name| dir.join(name).is_file()) { + return dir; + } + } + // Build one coherent archive set when the caller has not supplied one. + let mut build = Command::new(env!("CARGO")); + build + .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) + .args([ + "build", + "--profile", + "perry-dev", + "--message-format=json", + "-p", + "perry-runtime-static", + "-p", + "perry-stdlib-static", + "-p", + "perry-ext-net", + "-p", + "perry-ui-macos", + ]); + let output = checked(build); + let dir = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|item| item["reason"] == "compiler-artifact") + .filter_map(|item| item["filenames"].as_array().cloned()) + .flatten() + .filter_map(|file| file.as_str().map(PathBuf::from)) + .find(|file| file.file_name().is_some_and(|name| name == archives[0])) + .expect("runtime archive in Cargo output") + .parent() + .unwrap() + .to_path_buf(); + assert!(archives.iter().all(|name| dir.join(name).is_file())); + dir +} + +fn compile(root: &Path, runtime: &Path, source: &str, output: &str) -> PathBuf { + let mut command = Command::new(env!("CARGO_BIN_EXE_perry")); + command + .current_dir(root) + .env("PERRY_RUNTIME_DIR", runtime) + .env("PERRY_LL_OPT_LEVEL", "0") + .env_remove("PERRY_KEEP_SYMBOLS") + .env_remove("PERRY_DEBUG_SYMBOLS") + .args([ + "--format", + "json", + "compile", + source, + "-o", + output, + "--no-cache", + "--no-auto-optimize", + "--no-codegen", + "--emit-attest", + "--emit-sandbox", + ]); + let output = checked(command); + let result = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|item| item["success"] == true && item["output"].is_string()) + .expect("successful compilation JSON"); + root.join(result["output"].as_str().unwrap()) +} + +fn verify_bundle(app: &Path) { + let mut plutil = Command::new("/usr/bin/plutil"); + plutil + .args(["-convert", "json", "-o", "-"]) + .arg(app.join("Contents/Info.plist")); + let plist: serde_json::Value = serde_json::from_slice(&checked(plutil).stdout).unwrap(); + assert_eq!(plist["CFBundleIdentifier"], "dev.perry.bundle10078"); + assert_eq!(plist["CFBundleDisplayName"], "Perry & Bundle"); + assert_eq!(plist["CFBundleShortVersionString"], "2.3.4"); + assert_eq!(plist["CFBundleVersion"], "7"); + assert_eq!(plist["CFBundlePackageType"], "APPL"); + assert!(plist["NSCameraUsageDescription"].is_string()); + assert!(plist["NSMicrophoneUsageDescription"].is_string()); + let executable = app + .join("Contents/MacOS") + .join(plist["CFBundleExecutable"].as_str().unwrap()); + assert!(executable.is_file()); + assert_eq!( + std::fs::read_to_string(app.join("Contents/Resources/assets/message.txt")).unwrap(), + "bundle asset\n" + ); + assert!(app.with_extension("app.sandbox").is_file()); + assert!(!executable.with_extension("sandbox").exists()); + assert!(!executable.with_extension("attest.json").exists()); + let attestation: serde_json::Value = + serde_json::from_slice(&std::fs::read(app.with_extension("app.attest.json")).unwrap()) + .unwrap(); + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(&executable).unwrap(); + assert_eq!( + attestation["sha256"], + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + assert_eq!(attestation["size"], bytes.len() as u64); + let mut verify = Command::new("/usr/bin/codesign"); + verify.args(["--verify", "--deep", "--strict"]).arg(app); + checked(verify); +} + +#[test] +fn ui_outputs_are_signed_bundles_with_resources_and_final_binary_attestations() { + let runtime = runtime_dir(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("assets")).unwrap(); + std::fs::write(root.join("assets/message.txt"), "bundle asset\n").unwrap(); + std::fs::write(root.join("package.json"), r#"{"type":"module"}"#).unwrap(); + std::fs::write( + root.join("perry.toml"), + r#" +[project] +name = "bundle-test" +version = "2.3.4" +build_number = 7 +[macos] +bundle_id = "dev.perry.bundle10078" +display_name = "Perry & Bundle" +"#, + ) + .unwrap(); + std::fs::write( + root.join("main.ts"), + r#" +import { App, Text } from "perry/ui"; +App({ title: "Bundle test", body: Text("Bundle test") }); +"#, + ) + .unwrap(); + let app = compile(root, &runtime, "main.ts", "My App.v2"); + assert_eq!(app, root.join("My App.v2.app")); + assert!( + root.join("My App.v2").is_file(), + "keep the standalone output" + ); + verify_bundle(&app); + + // Explicit .app outputs link inside the bundle. Rebuilding must not strip + // after signing or truncate the executable while packaging it in place. + for _ in 0..2 { + let app = compile(root, &runtime, "main.ts", "nested/Explicit App.app"); + assert_eq!(app, root.join("nested/Explicit App.app")); + assert!(!root.join("nested/Explicit App").exists()); + verify_bundle(&app); + } + std::fs::write(root.join("cli.ts"), "console.log('plain cli');").unwrap(); + let cli = compile(root, &runtime, "cli.ts", "plain-cli"); + assert!(cli.is_file()); + assert!(!root.join("plain-cli.app").exists()); + let output = checked(Command::new(cli)); + assert_eq!(output.stdout, b"plain cli\n"); +} diff --git a/docs/src/ui/overview.md b/docs/src/ui/overview.md index 113f1f13c1..65a0c1982b 100644 --- a/docs/src/ui/overview.md +++ b/docs/src/ui/overview.md @@ -9,9 +9,22 @@ Perry's `perry/ui` module lets you build native desktop and mobile apps with dec ``` ```bash -perry app.ts -o app && ./app +perry run app.ts ``` +On macOS, UI builds produce an `.app` bundle so the system recognizes the +application's bundle identity. To compile and +launch separately, use `perry app.ts -o app && open app.app`. The linked +`app` executable is also kept; launch the bundle for desktop use. An explicit +`-o MyApp.app` writes the executable directly inside that bundle. + +The bundle includes project assets, localization resources, and the configured +app identity and version. `perry run` launches its executable with the supplied +arguments and terminal input/output. Command-line programs that do not use +`perry/ui` keep their standalone executable output. Optional `--emit-attest` +and `--emit-sandbox` sidecars are written beside the `.app`; the attestation +covers its signed executable in `Contents/MacOS`. + ## Mental Model Perry's UI follows the same model as SwiftUI and Flutter: you compose native widgets using stack-based layout containers (`VStack`, `HStack`, `ZStack`), control alignment and distribution, and style widgets via free functions that take the widget handle as their first argument (`textSetColor(label, r, g, b, a)`, `setPadding(stack, ...)`, etc.). If you're coming from web development, the key shift is: diff --git a/docs/src/ui/styling.md b/docs/src/ui/styling.md index a6adb98a95..ff9d46f2a2 100644 --- a/docs/src/ui/styling.md +++ b/docs/src/ui/styling.md @@ -160,6 +160,15 @@ Use `setPadding(widget, value)` for uniform padding, or The old `widgetSetEdgeInsets` name is deprecated and remains an alias during the deprecation window. +On macOS, padding applies to stacks, buttons (including borderless buttons), +`Text` and `AttributedText` labels, text fields, text areas, and scroll views. +Button and label padding adds to their natural size and moves the content +inside the existing background or border. Setting padding again replaces the +previous insets; setting it to zero restores the natural size. + +For other macOS controls, place the control inside a padded `VStack` or `HStack`. +Development builds print a diagnostic when a native view has no padding support. + ### Sizing ```typescript diff --git a/scripts/test-loop-lexical-tdz.mjs b/scripts/test-loop-lexical-tdz.mjs new file mode 100644 index 0000000000..c714ccf670 --- /dev/null +++ b/scripts/test-loop-lexical-tdz.mjs @@ -0,0 +1,54 @@ +// #10051: independent Node/native oracle, both lexical-scope lowering paths. +// PERRY_BIN and PERRY_RUNTIME_DIR must name one coherent compiler/runtime build. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-loop-tdz-')); +let passed = false; +try { + for (const type of ['commonjs', 'module']) { + const dir = path.join(work, type); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type })); + const source = path.join(dir, 'main.ts'); + fs.copyFileSync(path.join(root, 'test-files/test_gap_10051_loop_lexical_tdz.ts'), source); + const oracle = spawnSync(process.execPath, [source], { cwd: dir, timeout: 15_000 }); + if (oracle.status !== 0) throw new Error(`Node ${type} oracle failed: ${oracle.stderr}`); + fs.writeFileSync(path.join(dir, 'node.stdout'), oracle.stdout); + for (const gc of ['default', 'compact']) { + for (const opt of ['0', 's', 'z']) { + const label = `${type}-${gc}-O${opt}`; + const output = path.join(dir, label); + const env = { ...process.env, PERRY_LL_OPT_LEVEL: opt }; + for (const flag of ['PERRY_RS4GC', 'PERRY_SHADOW_STACK', + 'PERRY_INLINE_SHADOW_SLOT', 'PERRY_FULL_OUTLINE_IC']) delete env[flag]; + if (gc === 'compact') Object.assign(env, { + PERRY_RS4GC: '0', PERRY_SHADOW_STACK: '1', + PERRY_INLINE_SHADOW_SLOT: '0', PERRY_FULL_OUTLINE_IC: '1', + }); + const compile = spawnSync(compiler, ['compile', source, '-o', output, + '--no-cache', '--no-auto-optimize', '--no-codegen', '--no-color'], + { cwd: dir, env, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }); + fs.writeFileSync(output + '.compile.log', Buffer.concat([ + compile.stdout ?? Buffer.alloc(0), compile.stderr ?? Buffer.alloc(0)])); + if (compile.status !== 0) throw new Error(`${label} compile failed: ${compile.error ?? compile.status}`); + const run = spawnSync(output, [], { cwd: dir, env, timeout: 15_000 }); + fs.writeFileSync(output + '.stdout', run.stdout ?? Buffer.alloc(0)); + fs.writeFileSync(output + '.stderr', run.stderr ?? Buffer.alloc(0)); + if (run.status !== 0 || !run.stdout?.equals(oracle.stdout)) { + throw new Error(`${label} native/Node mismatch: ${run.error ?? run.status}\n${run.stdout ?? ''}${run.stderr ?? ''}`); + } + console.log(`PASS ${label}`); + } + } + } + passed = true; +} finally { + if (passed) fs.rmSync(work, { recursive: true }); + else console.error(`Retained regression diagnostics: ${work}`); +} diff --git a/test-files/test_gap_10051_loop_lexical_tdz.ts b/test-files/test_gap_10051_loop_lexical_tdz.ts new file mode 100644 index 0000000000..bcc548c833 --- /dev/null +++ b/test-files/test_gap_10051_loop_lexical_tdz.ts @@ -0,0 +1,167 @@ +// #10051: each block entry creates fresh captured lexical cells, including TDZ. +function test() { + const results: string[] = []; + for (let index = 0; index < 2; index++) { + let read = () => value; + try { read(); results.push('missing-tdz'); } + catch (error) { results.push(error instanceof ReferenceError ? 'tdz' : 'wrong-error'); } + let value: number; + results.push(String(read())); + value = index; + results.push(String(read())); + let recurse = (n: number): number => n === 0 ? value : recurse(n - 1); + results.push(String(recurse(2))); + } + return results.join(','); +} +console.log(test()); + +function probe(read: () => unknown): string { + let result: string; + try { result = String(read()); } + catch (error) { result = error instanceof ReferenceError ? 'tdz' : 'wrong-error'; } + return result; +} + +function retained() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const uninitializedRead = () => uninitialized; + const initializedRead = () => initialized; + const constantRead = () => constant; + results.push(probe(uninitializedRead), probe(initializedRead), probe(constantRead)); + let uninitialized: number; + results.push(probe(uninitializedRead)); + uninitialized = index; + let initialized = index + 10; + const constant = index + 20; + const recurse = (n: number): number => n === 0 ? initialized : recurse(n - 1); + reads.push(uninitializedRead, initializedRead, constantRead, () => recurse(2)); + initialized += 100; + } + console.log('retained-tdz:' + results.join(',')); + console.log('retained-values:' + reads.map(probe).join(',')); +} +retained(); + +function sharedVar() { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const read = () => value; + results.push(probe(read)); + var value = index; + reads.push(read); + } + console.log('var-before:' + results.join(',')); + console.log('var-retained:' + reads.map(probe).join(',')); +} +sharedVar(); + +function otherBlocks() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + let index = 0; + while (index < 2) { + { + const read = () => value; + results.push(probe(read)); + const value = index; + reads.push(read); + } + try { + const read = () => value; + results.push(probe(read)); + let value = index + 10; + reads.push(read); + } finally { + const read = () => value; + results.push(probe(read)); + let value = index + 20; + reads.push(read); + } + switch (index) { + case 0: + default: + const read = () => value; + results.push(probe(read)); + let value = index + 30; + reads.push(read); + case 99: + // Fallthrough stays in the same lexical environment. + results.push(probe(read)); + } + index++; + } + do { + const read = () => value; + results.push(probe(read)); + const value = index + 40; + reads.push(read); + index--; + } while (index > 0); + console.log('blocks-tdz:' + results.join(',')); + console.log('blocks-retained:' + reads.map(probe).join(',')); +} +otherBlocks(); + +function hoistedBlockFunctions() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 2; index++) { + results.push(probe(read)); + function read() { return value; } + let value = index; + reads.push(read); + } + console.log('hoisted-tdz:' + results.join(',')); + console.log('hoisted-retained:' + reads.map(probe).join(',')); +} +hoistedBlockFunctions(); + +// Function expressions use a separate function-body lowering path. +const expression = function () { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 2; index++) { + const read = () => value; + results.push(probe(read)); + let value = index; + reads.push(read); + } + return results.join(',') + ':' + reads.map(probe).join(','); +}; +console.log('expression:' + expression()); + +function abruptEntries() { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const read = () => value; + reads.push(read); + if (index === 1) continue; + let value = index; + } + console.log('skipped-declaration:' + reads.map(probe).join(',')); + for (let index = 0; index < 3; index++) { + try { + try { + if (index === 1) throw new Error('body'); + } finally { + const read = () => value; + results.push(probe(read)); + let value = index + 10; + reads.push(read); + } + } catch (error) { + results.push('caught'); + } + } + console.log('finally-paths:' + results.join(',')); + console.log('abrupt-retained:' + reads.map(probe).join(',')); +} +abruptEntries();