diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4beec267c..2de42af9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,27 @@ jobs: helper: "false" - run: pnpm build - run: pnpm test tests/harness/self-hosting-native-driver.test.ts + env: + SCRIPTC_BOOTSTRAP_PACKAGE_DIR: ${{ runner.temp }}/native-cli-package + - name: Verify packed compiler and Linux ABI + if: matrix.flavor == 'plain' + run: | + pnpm test packages/runtime/test/glibc-random.test.ts + node scripts/verify-native-cli.mjs "$RUNNER_TEMP/native-cli-package" --run + node scripts/smoke-native-install.mjs "$RUNNER_TEMP/native-cli-package" + - name: Install and compile on Amazon Linux 2023 (glibc 2.34) + if: matrix.flavor == 'plain' + run: | + NODE_ROOT=$(dirname "$(dirname "$(command -v node)")") + docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" \ + --volume "$RUNNER_TEMP/native-cli-package:/native:ro" --volume "$NODE_ROOT:/node:ro" \ + --env PATH=/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin \ + --workdir /work amazonlinux:2023 sh -c ' + set -eu + dnf install --assumeyes clang tar gzip + test "$(getconf GNU_LIBC_VERSION)" = "glibc 2.34" + node scripts/smoke-native-install.mjs /native + ' # A real host-clang/glibc build, not the zig cross-target lane: catches # Linux feature-test macro omissions at compile time and separate-libm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 163c575d9..1b181b5a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,18 @@ jobs: pnpm --filter @scriptc/compiler --filter scriptc build pnpm --filter @scriptc/${{ matrix.cli }} build:native node scripts/verify-native-cli.mjs packages/${{ matrix.cli }} --run + - name: Verify GNU npm distribution on glibc 2.34 + if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' + run: | + NODE_ROOT=$(dirname "$(dirname "$(command -v node)")") + docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" --volume "$NODE_ROOT:/node:ro" \ + --env PATH=/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin \ + --workdir /work amazonlinux:2023 sh -c ' + set -eu + dnf install --assumeyes clang tar gzip + test "$(getconf GNU_LIBC_VERSION)" = "glibc 2.34" + node scripts/smoke-native-install.mjs packages/${{ matrix.cli }} + ' - name: Verify packed Darwin helper on its build host if: matrix.platform == 'darwin-arm64' run: | diff --git a/README.md b/README.md index f92388f0f..1f3510ae4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ scriptc is experimental and targets macOS, Linux, Windows, and WebAssembly via W The installed compiler runs natively on supported macOS, Linux, and Windows hosts. Compilation, compile-time evaluation, and native execution do not require Node. `--emit=ir|llvm|asm|obj` uses the bundled TypeScript checker and LLVM helper without an external compiler, archiver, linker, or SDK. Executable builds additionally need a platform linker driver and SDK/sysroot; precompiled runtime packs supply the C runtime. Set `SCRIPTC_LINKER` to choose that driver. Runtime development with `--sanitize` additionally needs a C compiler. Node.js 24 or newer is needed for npm installation, development from a source checkout, the JavaScript compiler API, and `scriptc run` of WASI modules. +The native compiler includes its host runtime pack. Cross-compilation uses additional `@scriptc/runtime-` packages installed in your project at the same version as `scriptc --version`. For example, WASI builds use `@scriptc/runtime-wasm32-wasi`. Run the compiler from that project or set `SCRIPTC_RUNTIME_PACK` to the pack directory. Linux GNU distributions require glibc 2.34 or newer; the bundled LLVM helper is statically linked. + ```console $ npm install -g scriptc ``` diff --git a/benchmarks/builds/inventory-report/categories.ts b/benchmarks/builds/inventory-report/categories.ts new file mode 100644 index 000000000..8b5b83233 --- /dev/null +++ b/benchmarks/builds/inventory-report/categories.ts @@ -0,0 +1,17 @@ +import type { Category, Product } from "./types.ts"; +import { lowStock } from "./stock.ts"; +export function categories(products: Product[]): Category[] { + const groups = new Map(); + for (const product of products) { + let group = groups.get(product.category); + if (group === undefined) { + group = { name: product.category, products: 0, units: 0, value: 0, lowStock: 0 }; + groups.set(product.category, group); + } + group.products++; + group.units += product.quantity; + group.value += product.quantity * product.price; + group.lowStock += lowStock(product) ? 1 : 0; + } + return [...groups.values()]; +} diff --git a/benchmarks/builds/inventory-report/config.ts b/benchmarks/builds/inventory-report/config.ts new file mode 100644 index 000000000..fc825aede --- /dev/null +++ b/benchmarks/builds/inventory-report/config.ts @@ -0,0 +1,3 @@ +export const revision = 0; +export const lowStockThreshold = 8; +export const reorderTarget = 30; diff --git a/benchmarks/builds/inventory-report/load.ts b/benchmarks/builds/inventory-report/load.ts new file mode 100644 index 000000000..88bbdadf1 --- /dev/null +++ b/benchmarks/builds/inventory-report/load.ts @@ -0,0 +1,3 @@ +import { readFileSync } from "node:fs"; +import { parse } from "./parse.ts"; +export function load(file: string) { return parse(readFileSync(file, "utf8")); } diff --git a/benchmarks/builds/inventory-report/main.ts b/benchmarks/builds/inventory-report/main.ts new file mode 100644 index 000000000..00cce58e2 --- /dev/null +++ b/benchmarks/builds/inventory-report/main.ts @@ -0,0 +1,6 @@ +import { options } from "./options.ts"; +import { load } from "./load.ts"; +import { select } from "./select.ts"; +import { report } from "./report.ts"; +const args = options(process.argv.slice(2)); +console.log(report(select(load(args.file), args.category), args.limit)); diff --git a/benchmarks/builds/inventory-report/money.ts b/benchmarks/builds/inventory-report/money.ts new file mode 100644 index 000000000..66522736b --- /dev/null +++ b/benchmarks/builds/inventory-report/money.ts @@ -0,0 +1,3 @@ +export function money(cents: number): string { + return (cents / 100).toFixed(2); +} diff --git a/benchmarks/builds/inventory-report/normalize.ts b/benchmarks/builds/inventory-report/normalize.ts new file mode 100644 index 000000000..8fd00449e --- /dev/null +++ b/benchmarks/builds/inventory-report/normalize.ts @@ -0,0 +1,3 @@ +export function normalize(value: string): string { + return value.trim().toLowerCase(); +} diff --git a/benchmarks/builds/inventory-report/options.ts b/benchmarks/builds/inventory-report/options.ts new file mode 100644 index 000000000..701781efa --- /dev/null +++ b/benchmarks/builds/inventory-report/options.ts @@ -0,0 +1,15 @@ +export interface Options { file: string; category: string; limit: number } +export function options(args: string[]): Options { + let file = ""; + let category = ""; + let limit = 12; + for (let i = 0; i < args.length; i++) { + const argument = args[i]!; + if (argument === "--category") category = args[++i]!; + else if (argument === "--limit") limit = Number(args[++i]); + else if (file === "") file = argument; + else throw new Error("unexpected argument: " + argument); + } + if (file === "" || !Number.isInteger(limit) || limit < 1) throw new Error("expected an inventory file and positive limit"); + return { file, category, limit }; +} diff --git a/benchmarks/builds/inventory-report/parse.ts b/benchmarks/builds/inventory-report/parse.ts new file mode 100644 index 000000000..326a5bf6c --- /dev/null +++ b/benchmarks/builds/inventory-report/parse.ts @@ -0,0 +1,17 @@ +import type { Product } from "./types.ts"; +import { normalize } from "./normalize.ts"; +import { valid } from "./validate.ts"; +export function parse(source: string): Product[] { + const products: Product[] = []; + let lineNumber = 0; + for (const line of source.split("\n")) { + lineNumber++; + if (line.trim() === "") continue; + const fields = line.split("\t"); + if (fields.length !== 5) throw new Error("invalid inventory line " + lineNumber); + const product = { sku: fields[0]!, category: normalize(fields[1]!), name: fields[2]!, quantity: Number(fields[3]), price: Number(fields[4]) }; + if (!valid(product)) throw new Error("invalid product on line " + lineNumber); + products.push(product); + } + return products; +} diff --git a/benchmarks/builds/inventory-report/rank.ts b/benchmarks/builds/inventory-report/rank.ts new file mode 100644 index 000000000..67a86e5a4 --- /dev/null +++ b/benchmarks/builds/inventory-report/rank.ts @@ -0,0 +1,9 @@ +import type { Category, Product } from "./types.ts"; +import { reorderUnits } from "./stock.ts"; +export function rankCategories(rows: Category[]): Category[] { + return rows.sort((a, b) => b.value - a.value || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} +export function rankReorders(products: Product[]): Product[] { + return products.filter(product => reorderUnits(product) > 0).sort((a, b) => + reorderUnits(b) * b.price - reorderUnits(a) * a.price || (a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0)); +} diff --git a/benchmarks/builds/inventory-report/render.ts b/benchmarks/builds/inventory-report/render.ts new file mode 100644 index 000000000..7cea21746 --- /dev/null +++ b/benchmarks/builds/inventory-report/render.ts @@ -0,0 +1,11 @@ +import type { Category, Product } from "./types.ts"; +import { money } from "./money.ts"; +import { reorderUnits } from "./stock.ts"; +import { revision } from "./config.ts"; +export function render(groups: Category[], reorders: Product[], limit: number): string { + const lines = ["inventory report " + revision, "category,products,units,value,low_stock"]; + for (const group of groups) lines.push([group.name, group.products, group.units, money(group.value), group.lowStock].join(",")); + lines.push("reorder_sku,units,cost"); + for (const product of reorders.slice(0, limit)) lines.push([product.sku, reorderUnits(product), money(reorderUnits(product) * product.price)].join(",")); + return lines.join("\n"); +} diff --git a/benchmarks/builds/inventory-report/report.ts b/benchmarks/builds/inventory-report/report.ts new file mode 100644 index 000000000..4afe98386 --- /dev/null +++ b/benchmarks/builds/inventory-report/report.ts @@ -0,0 +1,7 @@ +import type { Product } from "./types.ts"; +import { categories } from "./categories.ts"; +import { rankCategories, rankReorders } from "./rank.ts"; +import { render } from "./render.ts"; +export function report(products: Product[], limit: number): string { + return render(rankCategories(categories(products)), rankReorders(products), limit); +} diff --git a/benchmarks/builds/inventory-report/select.ts b/benchmarks/builds/inventory-report/select.ts new file mode 100644 index 000000000..1f092ac0a --- /dev/null +++ b/benchmarks/builds/inventory-report/select.ts @@ -0,0 +1,6 @@ +import type { Product } from "./types.ts"; +import { normalize } from "./normalize.ts"; +export function select(products: Product[], category: string): Product[] { + const wanted = normalize(category); + return wanted === "" ? products : products.filter(product => product.category === wanted); +} diff --git a/benchmarks/builds/inventory-report/stock.ts b/benchmarks/builds/inventory-report/stock.ts new file mode 100644 index 000000000..d52b36820 --- /dev/null +++ b/benchmarks/builds/inventory-report/stock.ts @@ -0,0 +1,4 @@ +import type { Product } from "./types.ts"; +import { lowStockThreshold, reorderTarget } from "./config.ts"; +export function lowStock(product: Product): boolean { return product.quantity < lowStockThreshold; } +export function reorderUnits(product: Product): number { return lowStock(product) ? reorderTarget - product.quantity : 0; } diff --git a/benchmarks/builds/inventory-report/types.ts b/benchmarks/builds/inventory-report/types.ts new file mode 100644 index 000000000..e0ba99ed7 --- /dev/null +++ b/benchmarks/builds/inventory-report/types.ts @@ -0,0 +1,2 @@ +export interface Product { sku: string; category: string; name: string; quantity: number; price: number } +export interface Category { name: string; products: number; units: number; value: number; lowStock: number } diff --git a/benchmarks/builds/inventory-report/validate.ts b/benchmarks/builds/inventory-report/validate.ts new file mode 100644 index 000000000..5e17454e4 --- /dev/null +++ b/benchmarks/builds/inventory-report/validate.ts @@ -0,0 +1,6 @@ +import type { Product } from "./types.ts"; +export function valid(product: Product): boolean { + return product.sku.length > 0 && product.category.length > 0 && product.name.length > 0 && + Number.isInteger(product.quantity) && product.quantity >= 0 && + Number.isInteger(product.price) && product.price >= 0; +} diff --git a/benchmarks/builds/log-summary/aggregate.ts b/benchmarks/builds/log-summary/aggregate.ts new file mode 100644 index 000000000..51aa5d37d --- /dev/null +++ b/benchmarks/builds/log-summary/aggregate.ts @@ -0,0 +1,19 @@ +import { parse } from "./parse.ts"; +export interface Route { path: string; count: number; errors: number; milliseconds: number; bytes: number } +export function summarize(source: string, minimum: number): Route[] { + const routes = new Map(); + for (const line of source.split("\n")) { + const request = parse(line); + if (request === null || request.status < minimum) continue; + let route = routes.get(request.route); + if (route === undefined) { + route = { path: request.route, count: 0, errors: 0, milliseconds: 0, bytes: 0 }; + routes.set(request.route, route); + } + route.count++; + route.errors += request.status >= 400 ? 1 : 0; + route.milliseconds += request.milliseconds; + route.bytes += request.bytes; + } + return [...routes.values()].sort((a, b) => b.count - a.count || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} diff --git a/benchmarks/builds/log-summary/format.ts b/benchmarks/builds/log-summary/format.ts new file mode 100644 index 000000000..9403d76d3 --- /dev/null +++ b/benchmarks/builds/log-summary/format.ts @@ -0,0 +1,9 @@ +import type { Route } from "./aggregate.ts"; +export const revision = 0; +export function format(routes: Route[], limit: number): string { + const lines = ["request report " + revision, "route,count,errors,mean_ms,bytes"]; + for (const row of routes.slice(0, limit)) { + lines.push([row.path, row.count, row.errors, Math.round(row.milliseconds / row.count), row.bytes].join(",")); + } + return lines.join("\n"); +} diff --git a/benchmarks/builds/log-summary/main.ts b/benchmarks/builds/log-summary/main.ts new file mode 100644 index 000000000..37a3ca75d --- /dev/null +++ b/benchmarks/builds/log-summary/main.ts @@ -0,0 +1,6 @@ +import { readFileSync } from "node:fs"; +import { options } from "./options.ts"; +import { summarize } from "./aggregate.ts"; +import { format } from "./format.ts"; +const args = options(process.argv.slice(2)); +console.log(format(summarize(readFileSync(args.file, "utf8"), args.minimum), args.limit)); diff --git a/benchmarks/builds/log-summary/options.ts b/benchmarks/builds/log-summary/options.ts new file mode 100644 index 000000000..689c61b2d --- /dev/null +++ b/benchmarks/builds/log-summary/options.ts @@ -0,0 +1,17 @@ +export interface Options { file: string; minimum: number; limit: number } +export function options(args: string[]): Options { + let file = ""; + let minimum = 200; + let limit = 10; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--min-status") minimum = Number(args[++i]); + else if (arg === "--limit") limit = Number(args[++i]); + else if (file === "") file = arg; + else throw new Error("unexpected argument: " + arg); + } + if (file === "" || !Number.isInteger(minimum) || !Number.isInteger(limit) || limit < 1) { + throw new Error("usage: log-summary [--min-status ] [--limit ]"); + } + return { file, minimum, limit }; +} diff --git a/benchmarks/builds/log-summary/parse.ts b/benchmarks/builds/log-summary/parse.ts new file mode 100644 index 000000000..f654b9add --- /dev/null +++ b/benchmarks/builds/log-summary/parse.ts @@ -0,0 +1,12 @@ +export interface Request { route: string; status: number; milliseconds: number; bytes: number } +export function parse(line: string): Request | null { + const fields = line.split(" "); + if (fields.length !== 4) return null; + const status = Number(fields[1]); + const milliseconds = Number(fields[2]); + const bytes = Number(fields[3]); + if (!Number.isFinite(status) || !Number.isFinite(milliseconds) || !Number.isFinite(bytes)) return null; + const raw = fields[0]!; + const query = raw.indexOf("?"); + return { route: query < 0 ? raw : raw.slice(0, query), status, milliseconds, bytes }; +} diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx index 7431895c9..65ef6e934 100644 --- a/docs/src/app/platforms/page.mdx +++ b/docs/src/app/platforms/page.mdx @@ -6,6 +6,10 @@ The compiler runs natively and bundles its TypeScript checker and LLVM helper. O ## Cross-compilation via zig +The installed compiler includes its host runtime pack. Other runtime packs are optional project dependencies, pinned to the same version as scriptc --version. Install the appropriate @scriptc/runtime-<target> package, such as @scriptc/runtime-linux-arm64-gnu or @scriptc/runtime-wasm32-wasi, and run the compiler from your project. You can also set SCRIPTC_RUNTIME_PACK to a pack directory, including with standalone compiler archives. Adding a pack does not require reinstalling the compiler. Assembly and object output do not require a runtime pack. + +GNU/Linux compiler distributions and runtime packs support glibc 2.34 or newer. The LLVM helper is statically linked and does not require the build machine's libc or C++ runtime to be installed. + The installed host LLVM helper emits cross-target program objects and the matching runtime pack supplies precompiled support objects. Install [zig](https://ziglang.org) for Linux, Windows, and WASI cross-linking, then select the output target:
diff --git a/native/llvm-codegen/CMakeLists.txt b/native/llvm-codegen/CMakeLists.txt index 2fa51afa9..03420f2d1 100644 --- a/native/llvm-codegen/CMakeLists.txt +++ b/native/llvm-codegen/CMakeLists.txt @@ -14,9 +14,9 @@ set(SCRIPTC_ALLOWED_TARGETS "arm64-apple-macosx14.0.0,x86_64-apple-macosx14.0.0, set(SCRIPTC_HELPER_ARCH "arm64" CACHE STRING "macOS architecture of the helper executable") # LLVM's official Linux archives use the host libstdc++ and glibc ABIs. The -# musl package therefore ships this process-isolated helper as a fully static -# host executable; its configured output targets and runtime pack remain musl. -option(SCRIPTC_MUSL_BUILD "Configure the Linux musl helper package" OFF) +# Linux packages ship this process-isolated helper as a fully static host +# executable so it runs independently of the destination libc and libstdc++. +option(SCRIPTC_STATIC_LINUX_BUILD "Build a static Linux helper executable" OFF) # These must be cache variables before project() enables the Apple toolchain. # A target property named OSX_DEPLOYMENT_TARGET is not recognized by CMake and @@ -34,7 +34,7 @@ endif() project(scriptc_llvm_codegen LANGUAGES C CXX) if(CMAKE_C_COMPILER_TARGET MATCHES "linux-musl") - set(SCRIPTC_MUSL_BUILD ON) + set(SCRIPTC_STATIC_LINUX_BUILD ON) endif() # Official LLVM binary distributions retain zstd as an imported dependency in @@ -43,16 +43,16 @@ endif() find_package(zstd CONFIG QUIET) # LLVM's Linux archives retain ZLIB and zstd target edges in LLVMSupport. The -# musl package flattens that imported graph below and appends portable library +# static Linux helper flattens that imported graph below and appends portable library # names explicitly, so use interface placeholders instead of embedding host # library paths in the flattened graph. -if(SCRIPTC_MUSL_BUILD AND NOT TARGET ZLIB::ZLIB) +if(SCRIPTC_STATIC_LINUX_BUILD AND NOT TARGET ZLIB::ZLIB) add_library(ZLIB::ZLIB INTERFACE IMPORTED) endif() -if(SCRIPTC_MUSL_BUILD AND NOT TARGET zstd::libzstd_static) +if(SCRIPTC_STATIC_LINUX_BUILD AND NOT TARGET zstd::libzstd_static) add_library(zstd::libzstd_static INTERFACE IMPORTED) endif() -if(SCRIPTC_MUSL_BUILD AND NOT TARGET zstd::libzstd_shared) +if(SCRIPTC_STATIC_LINUX_BUILD AND NOT TARGET zstd::libzstd_shared) add_library(zstd::libzstd_shared INTERFACE IMPORTED) endif() @@ -126,11 +126,14 @@ target_compile_definitions(scriptc-llvm-codegen PRIVATE SCRIPTC_ALLOWED_TARGETS="${SCRIPTC_ALLOWED_TARGETS}" ) -if(SCRIPTC_MUSL_BUILD) +if(SCRIPTC_STATIC_LINUX_BUILD) # CMake's imported LLVM targets carry transitive archive interfaces that # duplicate most of this large static graph. Flatten it to one absolute - # archive path per target for the resource-constrained musl release link. + # archive path per target for the Linux release link. function(scriptc_collect_llvm_target target) + if(target STREQUAL "ZLIB::ZLIB" OR target MATCHES "^zstd::") + return() + endif() get_property(seen GLOBAL PROPERTY SCRIPTC_LLVM_LINK_SEEN) if(target IN_LIST seen) return() @@ -157,6 +160,17 @@ if(SCRIPTC_MUSL_BUILD) if(NOT location OR location MATCHES "-NOTFOUND$") get_target_property(location "${target}" IMPORTED_LOCATION) endif() + if(NOT location OR location MATCHES "-NOTFOUND$") + # Distribution packages can export RELWITHDEBINFO rather than RELEASE. + get_target_property(configurations "${target}" IMPORTED_CONFIGURATIONS) + foreach(configuration IN LISTS configurations) + string(TOUPPER "${configuration}" configuration) + get_target_property(location "${target}" "IMPORTED_LOCATION_${configuration}") + if(location AND NOT location MATCHES "-NOTFOUND$") + break() + endif() + endforeach() + endif() if(location AND NOT location MATCHES "-NOTFOUND$") set_property(GLOBAL APPEND PROPERTY SCRIPTC_LLVM_LINK_LIBS "${location}") endif() @@ -175,6 +189,9 @@ if(SCRIPTC_MUSL_BUILD) list(REMOVE_DUPLICATES SCRIPTC_LLVM_LIBS) list(REVERSE SCRIPTC_LLVM_LIBS) list(REMOVE_DUPLICATES SCRIPTC_LLVM_SYSTEM_LIBS) + # Some distribution builds attach Z3 to LLVMSupport. The helper never uses + # the solver, and its shared library cannot be part of a static executable. + list(FILTER SCRIPTC_LLVM_SYSTEM_LIBS EXCLUDE REGEX "(^|/)(lib)?([zZ]3|z|zstd)([.].*)?$") list(APPEND SCRIPTC_LLVM_SYSTEM_LIBS z zstd) else() llvm_map_components_to_libnames(SCRIPTC_LLVM_LIBS diff --git a/packages/cli/README.md b/packages/cli/README.md index d592eed9e..9aeeb89d5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,6 +24,8 @@ $ npm install -g scriptc The installed compiler runs natively on supported macOS, Linux, and Windows hosts. Compilation, compile-time evaluation, and native execution do not require Node. `--emit=ir|llvm|asm|obj` uses the bundled TypeScript checker and LLVM helper without an external compiler, archiver, linker, or SDK. Executable builds additionally need a platform linker driver and SDK/sysroot; precompiled runtime packs supply the C runtime. Set `SCRIPTC_LINKER` to choose that driver. Runtime development with `--sanitize` additionally needs a C compiler. Node.js 24 or newer is needed for npm installation and `scriptc run` of WASI modules. +The native compiler includes its host runtime pack. Cross-compilation uses additional `@scriptc/runtime-` packages installed in your project at the same version as `scriptc --version`. WASI builds use `@scriptc/runtime-wasm32-wasi`, for example. Run the compiler from that project or set `SCRIPTC_RUNTIME_PACK` to the pack directory. Linux GNU distributions require glibc 2.34 or newer. + Builds use a bounded persistent cache. Unchanged source can reuse the validated frontend result and LLVM program objects. Library identity getters occupy a separate LLVM module, so an identity change can reuse the large program object. Runtime objects come from the installed pack. Executable cache entries verify their native dependencies; FFI builds relink against current external inputs. Set `SCRIPTC_NO_CACHE=1` to bypass the cache or `SCRIPTC_CACHE_DIR` to select its location. An existing POSIX override must already be private. ## Commands diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a292c6cb..5018f18c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,17 +35,6 @@ "@scriptc/cli-linux-arm64-musl": "workspace:*", "@scriptc/cli-linux-x64-gnu": "workspace:*", "@scriptc/cli-linux-x64-musl": "workspace:*", - "@scriptc/cli-win32-x64-msvc": "workspace:*", - "@scriptc/runtime-darwin-arm64": "workspace:*", - "@scriptc/runtime-darwin-x64": "workspace:*", - "@scriptc/runtime-linux-x64-gnu": "workspace:*", - "@scriptc/runtime-linux-arm64-gnu": "workspace:*", - "@scriptc/runtime-linux-x64-musl": "workspace:*", - "@scriptc/runtime-linux-arm64-musl": "workspace:*", - "@scriptc/runtime-win32-x64-msvc": "workspace:*", - "@scriptc/runtime-wasm32-wasi": "workspace:*", - "@scriptc/runtime-ios-arm64": "workspace:*", - "@scriptc/runtime-ios-simulator-arm64": "workspace:*", - "@scriptc/runtime-android-arm64": "workspace:*" + "@scriptc/cli-win32-x64-msvc": "workspace:*" } } diff --git a/packages/cli/scripts/install-native.mjs b/packages/cli/scripts/install-native.mjs index 79a4c17f8..749f5d6e2 100644 --- a/packages/cli/scripts/install-native.mjs +++ b/packages/cli/scripts/install-native.mjs @@ -56,23 +56,6 @@ export function installNativeCli(directory, packageName = nativeCliPackage(proce const bin = join(directory, "bin"); mkdirSync(bin, { recursive: true }); const manifest = relocateToolchain(original, sourceDirectory, bin); - const packs = new Map((manifest.runtime_packs ?? []).map((pack) => [pack.target, pack])); - for (const suffix of ["darwin-arm64", "darwin-x64", "linux-x64-gnu", "linux-arm64-gnu", "linux-x64-musl", "linux-arm64-musl", - "win32-x64-msvc", "wasm32-wasi", "ios-arm64", "ios-simulator-arm64", "android-arm64"]) { - const name = `@scriptc/runtime-${suffix}`; - if (packageManifest.optionalDependencies?.[name] === undefined) continue; - let path; - try { path = require.resolve(`${name}/package.json`); } - catch { continue; } - const packIdentity = JSON.parse(readFileSync(path, "utf8")); - if (packIdentity.name !== name || packIdentity.version !== version) throw new Error(`scriptc requires ${name}@${version}`); - const pack = JSON.parse(readFileSync(join(dirname(path), "runtime-pack.json"), "utf8")); - if (pack.schema !== "scriptc.runtime-pack.v1" || pack.package !== name || pack.version !== version) { - throw new Error(`invalid runtime pack installed for ${name}@${version}`); - } - if (!packs.has(pack.target.name)) packs.set(pack.target.name, { target: pack.target.name, path: relative(bin, dirname(path)) }); - } - manifest.runtime_packs = [...packs.values()]; // npm normalizes modes for payloads outside package bin entries. These // tools are launched directly by the compiler after installation. if (process.platform !== "win32") { diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index 2fc947305..e36aa0ef1 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -2,7 +2,7 @@ * selected artifact is hashed and copied from the same read into private * storage. This host has no persistent cache or mutable installed link inputs. */ import { createHash } from "node:crypto"; -import { closeSync, constants, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; +import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; import { dirname, join } from "node:path"; import type { NativeLinkFeatures } from "./native-link-info.js"; import type { NativeTargetSpec } from "./targets.js"; @@ -30,6 +30,9 @@ export function selectNativeRuntimePack( root: string, target: NativeTargetSpec, compilerVersion: string, features: NativeLinkFeatures, flavor: "release" | "dev", mode: RuntimePackMode = "executable", ): NativeRuntimeSelection { + if (!existsSync(join(root, "package.json"))) { + throw new RuntimePackError(`runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`, "missing"); + } const packageText = readFileSync(join(root, "package.json"), "utf8"); const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8"); const identity = JSON.parse(packageText) as { name?: string; version?: string }; diff --git a/packages/compiler/src/backend/targets.test.ts b/packages/compiler/src/backend/targets.test.ts index 6eb585cb9..d7b9edb72 100644 --- a/packages/compiler/src/backend/targets.test.ts +++ b/packages/compiler/src/backend/targets.test.ts @@ -121,6 +121,14 @@ describe("native code-generation targets", () => { } }); + test.each(["x64", "arm64"])("GNU %s release links honor an explicit libc floor on the same host", (architecture) => { + const triple = `${architecture === "x64" ? "x86_64" : "aarch64"}-linux-gnu.2.34`; + expect(nativeCodegenTarget({ SCRIPTC_TARGET: triple }, "linux", architecture, "6.8.0", "gnu")) + .toMatchObject({ minimumOs: "glibc 2.34", defaultLinker: "zig", defaultLinkerArgs: ["cc"], linkerTargetTriple: triple }); + expect(nativeCodegenTarget({ SCRIPTC_TARGET: triple.replace(".2.34", "") }, "darwin", "arm64", "24.0.0")) + .toMatchObject({ minimumOs: "glibc 2.34", linkerTargetTriple: triple }); + }); + test("owns helper executable linker arguments in the target specification", () => { expect(MACOS_ARM64_TARGET.executableLinkerArgs).toEqual([ "-target", diff --git a/packages/compiler/src/backend/targets.ts b/packages/compiler/src/backend/targets.ts index cdd96a5db..228328b33 100644 --- a/packages/compiler/src/backend/targets.ts +++ b/packages/compiler/src/backend/targets.ts @@ -217,7 +217,7 @@ export const MACOS_X64_TARGET: NativeTargetSpec = { export const LINUX_X64_GNU_TARGET: NativeTargetSpec = { name: "linux-x64-gnu", llvmTriple: "x86_64-unknown-linux-gnu", dataLayout: X64_ELF_LAYOUT, cpu: "generic", features: "", pointerBits: 64, endianness: "little", objectFormat: "elf", - relocationModel: "pic", codeModel: "small", minimumOs: "glibc 2.36", helperMinimumOs: "glibc 2.35", + relocationModel: "pic", codeModel: "small", minimumOs: "glibc 2.34", helperMinimumOs: "static Linux executable", architecture: "x64", platform: "linux", outputSuffixes: { asm: ".s", obj: ".o", exe: "" }, executableLinkerArgs: ["-target", "x86_64-unknown-linux-gnu", "-pthread", "-Wl,--gc-sections"], defaultLinker: "clang", defaultLinkerArgs: [], runtimeSystemLibraries: ["m"], @@ -232,7 +232,7 @@ export const LINUX_X64_GNU_TARGET: NativeTargetSpec = { export const LINUX_ARM64_GNU_TARGET: NativeTargetSpec = { name: "linux-arm64-gnu", llvmTriple: "aarch64-unknown-linux-gnu", dataLayout: ARM64_ELF_LAYOUT, cpu: "generic", features: "", pointerBits: 64, endianness: "little", objectFormat: "elf", - relocationModel: "pic", codeModel: "small", minimumOs: "glibc 2.36", helperMinimumOs: "glibc 2.35", + relocationModel: "pic", codeModel: "small", minimumOs: "glibc 2.34", helperMinimumOs: "static Linux executable", architecture: "arm64", platform: "linux", outputSuffixes: { asm: ".s", obj: ".o", exe: "" }, executableLinkerArgs: ["-target", "aarch64-unknown-linux-gnu", "-pthread", "-Wl,--gc-sections"], defaultLinker: "clang", defaultLinkerArgs: [], runtimeSystemLibraries: ["m"], @@ -264,7 +264,7 @@ export const WINDOWS_X64_MSVC_TARGET: NativeTargetSpec = { export const LINUX_X64_MUSL_TARGET: NativeTargetSpec = { ...LINUX_X64_GNU_TARGET, name: "linux-x64-musl", llvmTriple: "x86_64-unknown-linux-musl", - minimumOs: "musl 1.2", helperMinimumOs: "glibc 2.35", + minimumOs: "musl 1.2", helperMinimumOs: "static Linux executable", executableLinkerArgs: ["-target", "x86_64-unknown-linux-musl", "-pthread", "-Wl,--gc-sections"], defaultLinker: "zig", defaultLinkerArgs: ["cc"], linkerTargetTriple: "x86_64-linux-musl", runtimePackPackage: "@scriptc/runtime-linux-x64-musl", @@ -276,7 +276,7 @@ export const LINUX_X64_MUSL_TARGET: NativeTargetSpec = { export const LINUX_ARM64_MUSL_TARGET: NativeTargetSpec = { ...LINUX_ARM64_GNU_TARGET, name: "linux-arm64-musl", llvmTriple: "aarch64-unknown-linux-musl", - minimumOs: "musl 1.2", helperMinimumOs: "glibc 2.35", + minimumOs: "musl 1.2", helperMinimumOs: "static Linux executable", executableLinkerArgs: ["-target", "aarch64-unknown-linux-musl", "-pthread", "-Wl,--gc-sections"], defaultLinker: "zig", defaultLinkerArgs: ["cc"], linkerTargetTriple: "aarch64-linux-musl", runtimePackPackage: "@scriptc/runtime-linux-arm64-musl", @@ -420,9 +420,11 @@ function requestedTarget( case "x86_64-macos": return MACOS_X64_TARGET; case "x86_64-unknown-linux-gnu": case "x86_64-linux-gnu": + case "x86_64-linux-gnu.2.34": case "x86_64-linux-gnu.2.36": return LINUX_X64_GNU_TARGET; case "aarch64-unknown-linux-gnu": case "aarch64-linux-gnu": + case "aarch64-linux-gnu.2.34": case "aarch64-linux-gnu.2.36": return LINUX_ARM64_GNU_TARGET; case "x86_64-unknown-linux-musl": case "x86_64-linux-musl": return LINUX_X64_MUSL_TARGET; @@ -445,10 +447,12 @@ export function selectNativeTarget( ): NativeTargetSpec | null { const target = requestedTarget(raw, host, hostPlatform as NodeJS.Platform); if (target === null) return null; - if (target.platform === "linux" && target.name !== host.name && target.name !== "android-arm64") { + if (target.platform === "linux" && target.name !== "android-arm64" && + (target.name !== host.name || raw.endsWith("-gnu.2.34") || raw.endsWith("-gnu.2.36"))) { return { ...target, defaultLinker: "zig", defaultLinkerArgs: ["cc"], - linkerTargetTriple: `${target.architecture === "x64" ? "x86_64" : "aarch64"}-linux-${target.name.endsWith("musl") ? "musl" : "gnu.2.36"}`, + linkerTargetTriple: raw.endsWith("-gnu.2.36") ? raw + : `${target.architecture === "x64" ? "x86_64" : "aarch64"}-linux-${target.name.endsWith("musl") ? "musl" : "gnu.2.34"}`, }; } if (target.platform === "darwin" && hostPlatform !== "darwin") { diff --git a/packages/compiler/src/native/toolchain.test.ts b/packages/compiler/src/native/toolchain.test.ts index 7197c61d8..74b718d65 100644 --- a/packages/compiler/src/native/toolchain.test.ts +++ b/packages/compiler/src/native/toolchain.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -21,6 +21,7 @@ test("native toolchain installation paths relocate with the manifest", () => { expect(toolchain.linker).toBe(join(root, "tools/linker")); expect(toolchain.linkerArgs).toEqual(["cc"]); expect(toolchain.target.name).toBe("macos-arm64"); + expect(loadNativeToolchain(path, { SCRIPTC_TARGET: "aarch64-apple-ios" }).target.name).toBe("ios-arm64"); for (const invalid of [null, [], {}, { ...manifest, schema: "other" }, { ...manifest, target: "other" }, { ...manifest, ts7: "" }, { ...manifest, linker_args: [1] }]) { writeFileSync(path, JSON.stringify(invalid)); @@ -28,3 +29,50 @@ test("native toolchain installation paths relocate with the manifest", () => { } } finally { rmSync(root, { recursive: true, force: true }); } }); + +test("cross-target packs can be added to a project after the compiler is installed", () => { + const root = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-project-pack-")); + const installation = join(root, "compiler", "bin"); + const project = join(root, "project"); + const nested = join(project, "src", "nested"); + const pack = join(project, "node_modules/@scriptc/runtime-wasm32-wasi"); + mkdirSync(installation, { recursive: true }); + mkdirSync(nested, { recursive: true }); + const path = join(installation, "scriptc.json"); + const manifest: NativeToolchainManifest = { + schema: "scriptc.native-toolchain.v1", compiler_version: "1.2.3", target: "macos-arm64", + ts7: "../lib/typescript/tsc", llvm_package: "../lib/llvm", runtime_pack: "../lib/runtime-darwin-arm64", + linker: "clang", linker_args: [], dsymutil: "dsymutil", + }; + writeFileSync(path, JSON.stringify(manifest)); + const env = { SCRIPTC_TARGET: "wasm32-wasi" }; + try { + // Object emission does not require a pack to be installed. + expect(loadNativeToolchain(path, env, nested).target.name).toBe("wasm32-wasi"); + mkdirSync(pack, { recursive: true }); + writeFileSync(join(pack, "package.json"), JSON.stringify({ name: "@scriptc/runtime-wasm32-wasi", version: "1.2.3" })); + expect(loadNativeToolchain(path, env, nested).runtimePackRoot).toBe(pack); + expect(loadNativeToolchain(path, { ...env, SCRIPTC_RUNTIME_PACK: "../explicit" }, nested).runtimePackRoot) + .toBe(join(root, "compiler", "explicit")); + expect(loadNativeToolchain(path, {}, nested).runtimePackRoot).toBe(join(root, "compiler", "lib/runtime-darwin-arm64")); + const registered = join(root, "registered"); + mkdirSync(registered); + writeFileSync(path, JSON.stringify({ ...manifest, runtime_packs: [{ target: "wasm32-wasi", path: registered }] })); + expect(loadNativeToolchain(path, env, nested).runtimePackRoot).toBe(registered); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("versioned GNU targets select Zig while ordinary host builds keep the installed linker", () => { + const root = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-gnu-linker-")); + const path = join(root, "scriptc.json"); + writeFileSync(path, JSON.stringify({ + schema: "scriptc.native-toolchain.v1", compiler_version: "1.2.3", target: "linux-x64-gnu", + ts7: "tsc", llvm_package: "llvm", runtime_pack: "runtime", linker: "clang", linker_args: [], dsymutil: "dsymutil", + })); + try { + expect(loadNativeToolchain(path).linker).toBe("clang"); + expect(loadNativeToolchain(path, { SCRIPTC_TARGET: "x86_64-linux-gnu.2.34" })).toMatchObject({ + linker: "zig", linkerArgs: ["cc"], target: { linkerTargetTriple: "x86_64-linux-gnu.2.34" }, + }); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/compiler/src/native/toolchain.ts b/packages/compiler/src/native/toolchain.ts index e045ac03d..d504f325e 100644 --- a/packages/compiler/src/native/toolchain.ts +++ b/packages/compiler/src/native/toolchain.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { NATIVE_TARGETS, selectNativeTarget, type NativeTargetSpec, type NativeHelperSpec } from "../backend/targets.js"; @@ -52,7 +52,20 @@ function commandFrom(root: string, value: string): string { return value.includes("/") || value.includes("\\") ? pathFrom(root, value) : value; } -export function loadNativeToolchain(path: string, env: NodeJS.ProcessEnv = {}): NativeToolchain { +/** Additional targets are ordinary project dependencies. Resolve them when + * requested so adding a pack never requires reinstalling the compiler. */ +function installedRuntimePack(start: string, packageName: string): string | undefined { + let directory = resolve(start); + for (;;) { + const candidate = join(directory, "node_modules", packageName); + if (existsSync(join(candidate, "package.json"))) return candidate; + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +export function loadNativeToolchain(path: string, env: NodeJS.ProcessEnv = {}, cwd: string = process.cwd()): NativeToolchain { const raw: unknown = JSON.parse(readFileSync(path, "utf8")); if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid native toolchain manifest"); const value = raw as Record; @@ -84,14 +97,20 @@ export function loadNativeToolchain(path: string, env: NodeJS.ProcessEnv = {}): const targets: readonly NativeTargetSpec[] = NATIVE_TARGETS; const host = targets.find((item) => item.name === manifest.target); if (host === undefined || host.platform === "wasi") throw new Error(`unsupported native compiler target: ${manifest.target}`); - const target = selectNativeTarget(env["SCRIPTC_TARGET"] ?? "", host); + const target = selectNativeTarget(env["SCRIPTC_TARGET"] ?? "", host, host.platform); if (target === null) throw new Error(`unsupported native compiler target: ${env["SCRIPTC_TARGET"]}`); const root = dirname(resolve(path)); const helperPackageRoot = pathFrom(root, env["SCRIPTC_LLVM_PACKAGE"] ?? manifest.llvm_package); - const runtimePath = manifest.runtime_packs?.find((pack) => pack.target === target.name)?.path + let runtimePath = manifest.runtime_packs?.find((pack) => pack.target === target.name)?.path ?? (target.name === host.name ? manifest.runtime_pack : join(dirname(manifest.runtime_pack), target.runtimePackPackage.replace("@scriptc/", ""))); - const linker = env["SCRIPTC_LINKER"] ?? (target.name === host.name ? manifest.linker : target.defaultLinker); - const linkerArgs = env["SCRIPTC_LINKER"] !== undefined ? [] : target.name === host.name ? manifest.linker_args : [...target.defaultLinkerArgs]; + if (target.name !== host.name && env["SCRIPTC_RUNTIME_PACK"] === undefined && !existsSync(pathFrom(root, runtimePath))) { + runtimePath = installedRuntimePack(cwd, target.runtimePackPackage) + ?? installedRuntimePack(root, target.runtimePackPackage) + ?? runtimePath; + } + const hostLinker = target.name === host.name && target.defaultLinker === host.defaultLinker; + const linker = env["SCRIPTC_LINKER"] ?? (hostLinker ? manifest.linker : target.defaultLinker); + const linkerArgs = env["SCRIPTC_LINKER"] !== undefined ? [] : hostLinker ? manifest.linker_args : [...target.defaultLinkerArgs]; return { compilerVersion: manifest.compiler_version, ts7Executable: pathFrom(root, manifest.ts7), diff --git a/packages/llvm-linux-arm64-gnu/scripts/build.mjs b/packages/llvm-linux-arm64-gnu/scripts/build.mjs index 515a2e61a..eace83421 100644 --- a/packages/llvm-linux-arm64-gnu/scripts/build.mjs +++ b/packages/llvm-linux-arm64-gnu/scripts/build.mjs @@ -7,5 +7,5 @@ import { fileURLToPath } from "node:url"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const repo = fileURLToPath(new URL("../../..", import.meta.url)); if (process.platform !== "linux" || process.arch !== "arm64") { process.stdout.write("@scriptc/llvm-linux-arm64-gnu: skipped on this host\n"); process.exit(0); } const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const build = join(repo, "node_modules/.cache/scriptc-llvm-linux-arm64-gnu"); -execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=aarch64-unknown-linux-gnu", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128-Fn32", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); +execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=aarch64-unknown-linux-gnu", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128-Fn32", ...nativeTargetCmakeArgs, "-DSCRIPTC_STATIC_LINUX_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); execFileSync("cmake", ["--build", build, "--target", "scriptc-llvm-codegen"], { stdio: "inherit" }); mkdirSync(join(root, "bin"), { recursive: true }); const output = join(root, "bin", "scriptc-llvm-codegen"); copyFileSync(join(build, "scriptc-llvm-codegen"), output); execFileSync("strip", [output], { stdio: "inherit" }); chmodSync(output, 0o755); diff --git a/packages/llvm-linux-arm64-musl/scripts/build.mjs b/packages/llvm-linux-arm64-musl/scripts/build.mjs index 87f832c47..d21bf2319 100644 --- a/packages/llvm-linux-arm64-musl/scripts/build.mjs +++ b/packages/llvm-linux-arm64-musl/scripts/build.mjs @@ -8,5 +8,5 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); const repo = file if (process.platform !== "linux" || process.arch !== "arm64") { process.stdout.write("@scriptc/llvm-linux-arm64-musl: skipped on this host\n"); process.exit(0); } const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const build = join(repo, "node_modules/.cache/scriptc-llvm-linux-arm64-musl"); -execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DCMAKE_C_COMPILER=${process.env.CC ?? "cc"}`, `-DCMAKE_CXX_COMPILER=${process.env.CXX ?? "c++"}`, "-DSCRIPTC_MUSL_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=aarch64-unknown-linux-musl", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128-Fn32", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); +execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DCMAKE_C_COMPILER=${process.env.CC ?? "cc"}`, `-DCMAKE_CXX_COMPILER=${process.env.CXX ?? "c++"}`, "-DSCRIPTC_STATIC_LINUX_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=aarch64-unknown-linux-musl", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128-Fn32", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); execFileSync("cmake", ["--build", build, "--target", "scriptc-llvm-codegen"], { stdio: "inherit" }); mkdirSync(join(root, "bin"), { recursive: true }); const output = join(root, "bin", "scriptc-llvm-codegen"); copyFileSync(join(build, "scriptc-llvm-codegen"), output); execFileSync("strip", [output], { stdio: "inherit" }); chmodSync(output, 0o755); diff --git a/packages/llvm-linux-x64-gnu/scripts/build.mjs b/packages/llvm-linux-x64-gnu/scripts/build.mjs index 02d55dda4..c0e06f36d 100644 --- a/packages/llvm-linux-x64-gnu/scripts/build.mjs +++ b/packages/llvm-linux-x64-gnu/scripts/build.mjs @@ -10,7 +10,7 @@ const repo = fileURLToPath(new URL("../../..", import.meta.url)); if (process.platform !== "linux" || process.arch !== "x64") { process.stdout.write("@scriptc/llvm-linux-x64-gnu: skipped on this host\n"); process.exit(0); } const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const build = join(repo, "node_modules/.cache/scriptc-llvm-linux-x64-gnu"); -execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=x86_64-unknown-linux-gnu", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); +execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=x86_64-unknown-linux-gnu", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128", ...nativeTargetCmakeArgs, "-DSCRIPTC_STATIC_LINUX_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); execFileSync("cmake", ["--build", build, "--target", "scriptc-llvm-codegen"], { stdio: "inherit" }); mkdirSync(join(root, "bin"), { recursive: true }); const output = join(root, "bin", "scriptc-llvm-codegen"); copyFileSync(join(build, "scriptc-llvm-codegen"), output); execFileSync("strip", [output], { stdio: "inherit" }); chmodSync(output, 0o755); diff --git a/packages/llvm-linux-x64-musl/scripts/build.mjs b/packages/llvm-linux-x64-musl/scripts/build.mjs index 03a217d82..27de017fb 100644 --- a/packages/llvm-linux-x64-musl/scripts/build.mjs +++ b/packages/llvm-linux-x64-musl/scripts/build.mjs @@ -10,5 +10,5 @@ const repo = fileURLToPath(new URL("../../..", import.meta.url)); if (process.platform !== "linux" || process.arch !== "x64") { process.stdout.write("@scriptc/llvm-linux-x64-musl: skipped on this host\n"); process.exit(0); } const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const build = join(repo, "node_modules/.cache/scriptc-llvm-linux-x64-musl"); -execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DCMAKE_C_COMPILER=${process.env.CC ?? "cc"}`, `-DCMAKE_CXX_COMPILER=${process.env.CXX ?? "c++"}`, "-DSCRIPTC_MUSL_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=x86_64-unknown-linux-musl", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); +execFileSync("cmake", ["-S", join(repo, "native/llvm-codegen"), "-B", build, "-G", "Ninja", `-DCMAKE_C_COMPILER=${process.env.CC ?? "cc"}`, `-DCMAKE_CXX_COMPILER=${process.env.CXX ?? "c++"}`, "-DSCRIPTC_STATIC_LINUX_BUILD=ON", "-DCMAKE_EXE_LINKER_FLAGS=-static", `-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`, `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, "-DSCRIPTC_DEFAULT_TARGET=x86_64-unknown-linux-musl", "-DSCRIPTC_DEFAULT_DATA_LAYOUT=e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128", ...nativeTargetCmakeArgs, "-DCMAKE_BUILD_TYPE=Release"], { stdio: "inherit" }); execFileSync("cmake", ["--build", build, "--target", "scriptc-llvm-codegen"], { stdio: "inherit" }); mkdirSync(join(root, "bin"), { recursive: true }); const output = join(root, "bin", "scriptc-llvm-codegen"); copyFileSync(join(build, "scriptc-llvm-codegen"), output); execFileSync("strip", [output], { stdio: "inherit" }); chmodSync(output, 0o755); diff --git a/packages/runtime-pack-common/scripts/glibc-toolchain.mjs b/packages/runtime-pack-common/scripts/glibc-toolchain.mjs index fac61e750..925621336 100644 --- a/packages/runtime-pack-common/scripts/glibc-toolchain.mjs +++ b/packages/runtime-pack-common/scripts/glibc-toolchain.mjs @@ -1,4 +1,4 @@ -const GLIBC_RUNTIME_FLOOR = "2.36"; +export const GLIBC_RUNTIME_FLOOR = "2.34"; const GNU_ARCHITECTURES = { x64: "x86_64", diff --git a/packages/runtime-pack-common/test/glibc-toolchain.test.ts b/packages/runtime-pack-common/test/glibc-toolchain.test.ts index e520cbc03..217bf0f97 100644 --- a/packages/runtime-pack-common/test/glibc-toolchain.test.ts +++ b/packages/runtime-pack-common/test/glibc-toolchain.test.ts @@ -2,11 +2,11 @@ import { expect, test } from "vitest"; import { glibcRuntimeToolchain } from "../scripts/glibc-toolchain.mjs"; test.each([ - ["x64", "x86_64-linux-gnu.2.36"], - ["arm64", "aarch64-linux-gnu.2.36"], + ["x64", "x86_64-linux-gnu.2.34"], + ["arm64", "aarch64-linux-gnu.2.34"], ])("pins the %s GNU runtime to its glibc floor", (architecture, target) => { expect(glibcRuntimeToolchain(architecture)).toEqual({ - minimumOs: "glibc 2.36", + minimumOs: "glibc 2.34", compiler: "zig", compilerArgs: ["cc"], archiver: "zig", diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 2792b480e..bec436fbc 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -78,6 +78,31 @@ void scr_runtime_abi_v5(void); * structured trap-teaching form before delivery (a message that already * begins with the 0x01 marker passes verbatim) — see ScrLibSinkFn below. */ _Noreturn void scr_trap(const char *msg); + +/* glibc added arc4random_buf in 2.36. Older GNU targets use the same kernel + * CSPRNG as the musl shim, with no global state or persistent descriptor. */ +#ifdef __GLIBC__ +#if !__GLIBC_PREREQ(2, 36) +#include +#include +#include +static inline void scr_glibc_random_buf(void *buf, size_t n) { + unsigned char *p = buf; + while (n > 0) { + ssize_t got = getrandom(p, n, 0); + if (got > 0) { + p += (size_t)got; + n -= (size_t)got; + } else if (got < 0 && errno == EINTR) { + continue; + } else { + scr_trap("scriptc: getrandom failed\n"); + } + } +} +#define arc4random_buf scr_glibc_random_buf +#endif +#endif _Noreturn void scr_trap_fmt(const char *fmt, ...); /* ── library mode (scr_library.c, linked only into library artifacts) ───── diff --git a/packages/runtime/test/glibc-random.test.ts b/packages/runtime/test/glibc-random.test.ts new file mode 100644 index 000000000..71dd856da --- /dev/null +++ b/packages/runtime/test/glibc-random.test.ts @@ -0,0 +1,19 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +const report = process.platform === "linux" ? process.report.getReport() as { header: { glibcVersionRuntime?: string } } : undefined; +const version = report?.header.glibcVersionRuntime?.split(".").map(Number); +const supported = version !== undefined && (version[0]! > 2 || (version[0] === 2 && version[1]! >= 34)) && + (process.arch === "x64" || process.arch === "arm64"); +test.skipIf(!supported)("glibc 2.34 CSPRNG retries interruptions and partial reads and traps on entropy failure", () => { + const directory = mkdtempSync("/tmp/scriptc-glibc-random-"); + const binary = join(directory, "random"); + try { + execFileSync("zig", ["cc", "-target", `${process.arch === "x64" ? "x86_64" : "aarch64"}-linux-gnu.2.34`, + "-std=c11", "-O2", "-D_GNU_SOURCE", "-I", join(import.meta.dirname, "../src"), + join(import.meta.dirname, "test_glibc_random.c"), "-o", binary], { timeout: 120_000 }); + expect(execFileSync(binary, [], { encoding: "utf8" })).toBe("entropy retries and failures verified\n"); + } finally { rmSync(directory, { recursive: true, force: true }); } +}, 120_000); diff --git a/packages/runtime/test/test_glibc_random.c b/packages/runtime/test/test_glibc_random.c new file mode 100644 index 000000000..ab1aeb42c --- /dev/null +++ b/packages/runtime/test/test_glibc_random.c @@ -0,0 +1,45 @@ +#include "scr_runtime.h" +#include +#include +#include +#include + +static jmp_buf panic; +static int calls; +static int mode; +static unsigned char next_byte; + +ssize_t getrandom(void *buf, size_t n, unsigned int flags) { + assert(flags == 0); + calls++; + if (mode == 1) return 0; + if (mode == 2) { errno = EIO; return -1; } + if (calls == 1) { errno = EINTR; return -1; } + size_t count = n < 3 ? n : 3; + unsigned char *bytes = buf; + for (size_t i = 0; i < count; i++) bytes[i] = next_byte++; + return (ssize_t)count; +} + +_Noreturn void scr_trap(const char *message) { + assert(strcmp(message, "scriptc: getrandom failed\n") == 0); + longjmp(panic, 1); +} + +int main(void) { + unsigned char bytes[17]; + memset(bytes, 255, sizeof bytes); + arc4random_buf(bytes, 0); + assert(calls == 0); + arc4random_buf(bytes, sizeof bytes); + assert(calls == 7); + for (size_t i = 0; i < sizeof bytes; i++) assert(bytes[i] == i); + for (mode = 1; mode <= 2; mode++) { + if (setjmp(panic) == 0) { + arc4random_buf(bytes, sizeof bytes); + assert(0 && "entropy failure must trap"); + } + } + puts("entropy retries and failures verified"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41115fd9..4284ef410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,39 +74,6 @@ importers: '@scriptc/cli-win32-x64-msvc': specifier: workspace:* version: link:../cli-win32-x64-msvc - '@scriptc/runtime-android-arm64': - specifier: workspace:* - version: link:../runtime-android-arm64 - '@scriptc/runtime-darwin-arm64': - specifier: workspace:* - version: link:../runtime-darwin-arm64 - '@scriptc/runtime-darwin-x64': - specifier: workspace:* - version: link:../runtime-darwin-x64 - '@scriptc/runtime-ios-arm64': - specifier: workspace:* - version: link:../runtime-ios-arm64 - '@scriptc/runtime-ios-simulator-arm64': - specifier: workspace:* - version: link:../runtime-ios-simulator-arm64 - '@scriptc/runtime-linux-arm64-gnu': - specifier: workspace:* - version: link:../runtime-linux-arm64-gnu - '@scriptc/runtime-linux-arm64-musl': - specifier: workspace:* - version: link:../runtime-linux-arm64-musl - '@scriptc/runtime-linux-x64-gnu': - specifier: workspace:* - version: link:../runtime-linux-x64-gnu - '@scriptc/runtime-linux-x64-musl': - specifier: workspace:* - version: link:../runtime-linux-x64-musl - '@scriptc/runtime-wasm32-wasi': - specifier: workspace:* - version: link:../runtime-wasm32-wasi - '@scriptc/runtime-win32-x64-msvc': - specifier: workspace:* - version: link:../runtime-win32-x64-msvc packages/cli-darwin-arm64: {} diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index ab608c300..13aa8f2cc 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -1,9 +1,9 @@ -/* Reproducible CLI build latency: one empty-cache build, exact repeats, and +/* Reproducible CLI build latency: empty-cache builds, exact repeats, and * actual edits to an imported module. Each invocation owns a fresh cache so * an earlier benchmark cannot turn its edit cases into native object hits. */ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,8 +17,21 @@ const { values } = parseArgs({ options: { functions: { type: "string", default: "16" }, timings: { type: "boolean", default: false }, strip: { type: "boolean", default: false }, + workload: { type: "string", default: "modules" }, + memory: { type: "boolean", default: false }, + launches: { type: "string", default: "20" }, + help: { type: "boolean", default: false }, } }); +if (values.help) { + console.log("Usage: pnpm bench:builds [--compiler=] [--workload=modules|log-summary|inventory-report] [--iterations=5] [--optimization=dev|release] [--memory] [--timings] [--launches=20] [--modules=16] [--functions=16] [--strip]"); + console.log("Reports empty-cache builds, unchanged builds, edits, executable size, and end-to-end run time. --memory reports peak process RSS using /usr/bin/time, not summed concurrent memory. The modules workload is generated; log-summary and inventory-report exercise file-processing applications."); + process.exit(0); +} const iterations = Number(values.iterations); +const launches = Number(values.launches); +if (!Number.isInteger(launches) || launches < 1 || launches > 1000) throw new Error("--launches must be an integer between 1 and 1000"); +if (!["modules", "log-summary", "inventory-report"].includes(values.workload)) throw new Error("--workload must be modules, log-summary, or inventory-report"); +if (values.memory && process.platform !== "darwin" && process.platform !== "linux") throw new Error("--memory requires macOS or Linux /usr/bin/time"); if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100) { throw new Error("--iterations must be an integer between 1 and 100"); } @@ -43,6 +56,9 @@ delete env.SCRIPTC_CACHE_MAX_MB; delete env.SCRIPTC_TEST_STABLE_TOOLCHAIN; delete env.SCRIPTC_TIMING; const samples = []; +let previousOutput; +const runtimeArgs = values.workload === "log-summary" ? [join(root, "requests.log"), "--min-status", "200", "--limit", "12"] + : values.workload === "inventory-report" ? [join(root, "inventory.tsv")] : []; function moduleSource(module, offset) { return Array.from({ length: functions }, (_, fn) => @@ -50,32 +66,43 @@ function moduleSource(module, offset) { ).join("\n") + "\n"; } -function run(command, args, timings = false) { - const result = spawnSync(command, args, { +function run(command, args, timings = false, memory = false) { + const measuredArgs = process.platform === "darwin" ? ["-l", command, ...args] : ["-f", "scriptc_peak_rss_kib=%M", command, ...args]; + const result = spawnSync(memory ? "/usr/bin/time" : command, memory ? measuredArgs : args, { env: timings ? { ...env, SCRIPTC_TIMING: "1" } : env, - encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 120_000, + encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 600_000, }); if (result.error) throw result.error; assert.equal(result.signal, null, `${command} received ${result.signal}`); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + let stderr = result.stderr; + let peakRssBytes; + if (memory) { + const match = process.platform === "darwin" ? /\s+(\d+)\s+maximum resident set size/.exec(stderr) : /scriptc_peak_rss_kib=(\d+)/.exec(stderr); + assert.ok(match, "time did not report peak RSS"); + peakRssBytes = Number(match[1]) * (process.platform === "darwin" ? 1 : 1024); + stderr = process.platform === "darwin" ? stderr.replace(/\s+\d+(?:\.\d+)? real\s+[\s\S]*$/, "") : stderr.replace(/scriptc_peak_rss_kib=\d+\n?/, ""); + } + return { stdout: result.stdout, stderr, status: result.status, ...(peakRssBytes === undefined ? {} : { peakRssBytes }) }; } function build(phase) { const start = performance.now(); const args = ["build", entry, `--optimization=${values.optimization}`, "-o", binary, ...(values.strip ? ["--strip"] : [])]; - const result = values.compiler ? run(cli, args, values.timings) : run(process.execPath, [cli, ...args], values.timings); + const result = values.compiler ? run(cli, args, values.timings, values.memory) : run(process.execPath, [cli, ...args], values.timings, values.memory); const ms = Math.round((performance.now() - start) * 10) / 10; assert.equal(result.status, 0, result.stderr); // Correctness checks are outside the timed build and run after EVERY edit, // so a stale executable cannot masquerade as a faster rebuild. - const oracle = run(process.execPath, [entry]); + const oracle = run(process.execPath, [entry, ...runtimeArgs]); assert.equal(oracle.status, 0, oracle.stderr); - assert.deepEqual(run(binary, []), oracle); + if (phase === "edit") assert.notEqual(oracle.stdout, previousOutput, "the edit must change the application's output"); + assert.deepEqual(run(binary, runtimeArgs), oracle); + previousOutput = oracle.stdout; const timings = result.stderr.split("\n").flatMap((line) => { const prefix = "scriptc timing "; return line.startsWith(prefix) ? [JSON.parse(line.slice(prefix.length))] : []; }); - samples.push({ phase, ms, ...(values.timings ? { timings } : {}) }); + samples.push({ phase, ms, ...(values.timings ? { timings } : {}), ...(values.memory ? { peak_rss_bytes: result.peakRssBytes } : {}) }); process.stderr.write(`${phase}: ${ms} ms\n`); } @@ -87,20 +114,54 @@ function median(phase) { try { await mkdir(cache, { mode: 0o700 }); - await Promise.all(Array.from({ length: modules }, (_, i) => writeFile(join(root, `module${i}.ts`), moduleSource(i, 0)))); - await writeFile(entry, [ - ...Array.from({ length: modules }, (_, i) => `import * as m${i} from './module${i}.ts';`), - "let total = 0;", - ...Array.from({ length: modules }, (_, i) => `total += ${Array.from({ length: functions }, (_, fn) => `m${i}.value${fn}(2)`).join(" + ")};`), - "console.log(total);", - "", - ].join("\n")); - build("cold"); + if (values.workload !== "modules") { + await cp(fileURLToPath(new URL(`../benchmarks/builds/${values.workload}`, import.meta.url)), root, { recursive: true }); + await writeFile(join(root, "package.json"), '{"type":"module"}\n'); + } + if (values.workload === "log-summary") { + await writeFile(join(root, "requests.log"), Array.from({ length: 10_000 }, (_, i) => + `/route/${i % 37}?request=${i} ${i % 13 === 0 ? 500 : 200} ${i % 250} ${200 + i % 4000}`, + ).join("\n") + "\n"); + } else if (values.workload === "inventory-report") { + await writeFile(join(root, "inventory.tsv"), Array.from({ length: 10_000 }, (_, i) => + ["sku-" + i, "category-" + i % 17, "product " + i, i % 40, 199 + i % 5000].join("\t"), + ).join("\n") + "\n"); + } else { + await Promise.all(Array.from({ length: modules }, (_, i) => writeFile(join(root, `module${i}.ts`), moduleSource(i, 0)))); + await writeFile(entry, [ + ...Array.from({ length: modules }, (_, i) => `import * as m${i} from './module${i}.ts';`), + "let total = 0;", + ...Array.from({ length: modules }, (_, i) => `total += ${Array.from({ length: functions }, (_, fn) => `m${i}.value${fn}(2)`).join(" + ")};`), + "console.log(total);", + "", + ].join("\n")); + } + for (let i = 0; i < iterations; i++) { + if (i > 0) { + await rm(cache, { recursive: true, force: true }); + await mkdir(cache, { mode: 0o700 }); + } + build("cold"); + } for (let i = 0; i < iterations; i++) build("unchanged"); for (let i = 1; i <= iterations; i++) { - await writeFile(join(root, "module0.ts"), moduleSource(0, i)); + if (values.workload !== "modules") { + const path = join(root, values.workload === "log-summary" ? "format.ts" : "config.ts"); + await writeFile(path, (await readFile(path, "utf8")).replace(/export const revision = \d+;/, `export const revision = ${i};`)); + } else await writeFile(join(root, "module0.ts"), moduleSource(0, i)); build("edit"); } + const runtimeSamples = []; + for (let i = -3; i < launches; i++) { + const start = performance.now(); + const result = run(binary, runtimeArgs); + assert.equal(result.status, 0, result.stderr); + if (i >= 0) runtimeSamples.push(performance.now() - start); + } + runtimeSamples.sort((a, b) => a - b); + const memoryResult = values.memory ? run(binary, runtimeArgs, false, true) : undefined; + if (memoryResult !== undefined) assert.equal(memoryResult.status, 0, memoryResult.stderr); + const runtimeMemory = memoryResult?.peakRssBytes; process.stdout.write(JSON.stringify({ compiler: values.compiler ?? "workspace CLI", node: process.version, @@ -108,11 +169,18 @@ try { arch: process.arch, optimization: values.optimization, strip: values.strip, - modules: modules + 1, - functions: modules * functions, + workload: values.workload, + ...(values.workload === "modules" ? { modules: modules + 1, functions: modules * functions } + : { application_modules: values.workload === "log-summary" ? 5 : 15, input_records: 10_000 }), iterations, median_ms: Object.fromEntries(["cold", "unchanged", "edit"].map((phase) => [phase, median(phase)])), samples, + executable: { + bytes: (await stat(binary)).size, + median_run_ms: (runtimeSamples[Math.floor((launches - 1) / 2)] + runtimeSamples[Math.floor(launches / 2)]) / 2, + runs: launches, + ...(runtimeMemory === undefined ? {} : { peak_rss_bytes: runtimeMemory }), + }, }, null, 2) + "\n"); } finally { await rm(root, { recursive: true, force: true }); diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index b6ac6e2f7..0bd8dc929 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -11,21 +11,31 @@ import type { NativeToolchainManifest } from "../packages/compiler/src/native/to const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const output = resolve(process.argv[2] ?? join(root, ".scriptc/native-cli")); -const target = nativeCodegenTarget(); +let target = nativeCodegenTarget(); if (target === null || target.platform === "wasi") throw new Error("a supported native host is required to build the compiler"); +// Release executables use the runtime pack's libc baseline, even when the +// build host has a newer libc. Sanitizer builds use the host's sanitizer SDK. +if (target.name.endsWith("-gnu") && process.env["SCRIPTC_SAN"] !== "1") { + const architecture = target.architecture === "x64" ? "x86_64" : "aarch64"; + process.env["SCRIPTC_TARGET"] = `${architecture}-linux-gnu.2.34`; + target = nativeCodegenTarget()!; +} const bin = join(output, "bin"); const lib = join(output, "lib"); const seed = join(dirname(output), ".scriptc", basename(output) + "-seed"); for (const dir of [bin, lib, seed]) mkdirSync(dir, { recursive: true }); const compiler = process.env["SCRIPTC_CC"] ?? target.defaultLinker; const compilerArgs = process.env["SCRIPTC_CC"] === undefined ? [...target.defaultLinkerArgs] : []; +// Zig's C debug mode otherwise inserts UBSan calls into these objects. They +// must also link through the destination host's ordinary compiler driver. +const cFlags = ["-std=c11", "-fno-sanitize=undefined", "-Wall", "-Wextra", "-Werror", "-target", target.linkerTargetTriple]; const nativeSources = join(root, "packages/compiler/native"); const functions: unknown[] = []; const libraries: string[] = []; for (const name of ["ts7-process", "host"]) { const object = join(seed, name + target.outputSuffixes.obj); execFileSync(compiler, [ - ...compilerArgs, "-std=c11", "-Wall", "-Wextra", "-Werror", "-target", target.linkerTargetTriple, + ...compilerArgs, ...cFlags, "-c", join(nativeSources, name + ".c"), "-o", object, ], { stdio: "inherit" }); const manifest = JSON.parse(readFileSync(join(nativeSources, name + ".ffi.json"), "utf8")) as { functions: unknown[] }; @@ -37,7 +47,7 @@ writeFileSync(ffi, JSON.stringify({ ffi_format: 6, functions, libraries }, null, const executable = join(bin, "scriptc" + target.outputSuffixes.exe); const comptime = join(lib, "scriptc-comptime" + target.outputSuffixes.exe); execFileSync(compiler, [ - ...compilerArgs, "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-target", target.linkerTargetTriple, + ...compilerArgs, ...cFlags, "-O2", "-I", join(root, "packages/runtime/vendor/quickjs-ng"), join(nativeSources, "comptime.c"), join(root, "packages", target.runtimePackPackage.replace("@scriptc/", ""), "artifacts/vendor/quickjs/libscriptc-quickjs.a"), "-lm", "-lpthread", "-o", comptime, diff --git a/scripts/package-native-cli.mjs b/scripts/package-native-cli.mjs index 900589e1f..be023c149 100644 --- a/scripts/package-native-cli.mjs +++ b/scripts/package-native-cli.mjs @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -10,7 +10,6 @@ const packages = resolve(process.argv[2] ?? join(repository, "packages")); const output = resolve(process.argv[3] ?? join(repository, ".scriptc/releases")); mkdirSync(output, { recursive: true }); const compiler = JSON.parse(readFileSync(join(packages, "compiler/package.json"), "utf8")); -const runtimes = Object.keys(compiler.optionalDependencies).filter((name) => name.startsWith("@scriptc/runtime-")); const sums = []; for (const name of readdirSync(packages).filter((name) => name.startsWith("cli-")).sort()) { @@ -26,20 +25,6 @@ for (const name of readdirSync(packages).filter((name) => name.startsWith("cli-" const binary = join(bin, name.includes("win32-") ? "scriptc.exe" : "scriptc"); const manifest = JSON.parse(readFileSync(binary + ".json", "utf8")); const hostPath = (value) => name.includes("win32-") ? value.replaceAll("\\", "/") : value; - const packs = new Map(); - for (const packageName of runtimes) { - const directory = packageName.replace("@scriptc/", ""); - const from = join(packages, directory); - const to = join(distribution, "lib", directory); - const runtime = JSON.parse(readFileSync(join(from, "runtime-pack.json"), "utf8")); - if (runtime.version !== identity.version || runtime.package !== packageName) throw new Error(`runtime version mismatch: ${packageName}`); - for (const member of ["package.json", "runtime-pack.json", "artifacts"]) { - cpSync(join(from, member), join(to, member), { recursive: true }); - } - packs.set(runtime.target.name, { target: runtime.target.name, path: relative(bin, to) }); - } - manifest.runtime_packs = [...packs.values()]; - writeFileSync(binary + ".json", JSON.stringify(manifest, null, 2) + "\n"); // GitHub artifact transport drops executable modes. Restore the native // tools before validating and creating the standalone archive. for (const path of [binary, resolve(bin, hostPath(manifest.ts7)), resolve(bin, hostPath(manifest.comptime)), diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index c1936b6c5..1b91988c3 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -48,6 +48,10 @@ const invariantCaseShardedFiles = ["tests/harness/coverage.test.ts"]; // independent cases can spread across the once lane's Sandboxes without // colliding with corpus case selection. const cacheCaseShardedFiles = ["packages/compiler/src/backend/native-toolchain.test.ts"]; +const nativeBuildFiles = [ + "tests/harness/self-hosting-native-driver.test.ts", + "packages/runtime/test/glibc-random.test.ts", +]; const caseShardedFiles = [ ...laneCaseShardedFiles, ...invariantCaseShardedFiles, @@ -745,14 +749,17 @@ try { "/workspace", 3 * 60_000, ); - // Zig is a build-only dependency in this lane. Cross-target suites own - // the conditional Zig tests; exposing it here would silently expand the - // native-cache shard while that shard deliberately disables stable - // toolchain caching. + // Keep Zig available to the portable compiler bootstrap and entropy + // contract, without expanding the conditional cross-target cache tests. await execIn( worker, - "sudo", - ["rm", "-f", "/usr/local/bin/zig"], + "sh", + ["-c", [ + "set -eu", + "mkdir -p /tmp/scriptc-native-build-tools", + 'ln -sf "$(readlink -f "$(command -v zig)")" /tmp/scriptc-native-build-tools/zig', + "sudo rm -f /usr/local/bin/zig", + ].join("\n")], {}, "runtime toolchain cleanup", 60_000, @@ -805,6 +812,7 @@ try { "--passWithNoTests", `--shard=${worker.shard}/${shardCount}`, ...caseShardedFiles.map((file) => `--exclude=${file}`), + ...nativeBuildFiles.map((file) => `--exclude=${file}`), ...nativeHostInvariantFiles.map((file) => `--exclude=${file}`), ...localLaneFiles.map((file) => `--exclude=${file}`), ...localCaseShardedFiles.map((file) => `--exclude=${file}`), @@ -837,9 +845,17 @@ try { }, "cache", ); + const nativeBuild = () => execIn( + worker, + "sh", + ["-c", 'export PATH="/tmp/scriptc-native-build-tools:$PATH"; exec "$@"', "sh", + "pnpm", "test", "--reporter=dot", ...nativeBuildFiles], + { ...sharedTestEnv, SCRIPTC_TEST_WORKERS: "1" }, + "native bootstrap", + ); // The corpus owns the remaining pool while file/cache processes share // the reserved side slots (serially when only one slot is available). - const sideTasks = [files, ...(runCacheCases ? [cacheCases] : [])]; + const sideTasks = [...(worker.shard === 1 ? [nativeBuild] : []), files, ...(runCacheCases ? [cacheCases] : [])]; if (sideConcurrency === 0) { await cases(); await runTaskQueue(sideTasks, 1); diff --git a/scripts/smoke-native-install.mjs b/scripts/smoke-native-install.mjs new file mode 100644 index 000000000..ddb0bdb00 --- /dev/null +++ b/scripts/smoke-native-install.mjs @@ -0,0 +1,69 @@ +/** Exercise the packed npm command and its bundled host assets in isolation. */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareNativeCommand } from "../packages/cli/scripts/prepare-native.mjs"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const platform = resolve(process.argv[2]); +const identity = JSON.parse(readFileSync(join(platform, "package.json"), "utf8")); +const root = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-npm-smoke-")); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const env = { ...process.env, npm_config_cache: join(root, "npm-cache"), npm_config_update_notifier: "false", NODE_PATH: "" }; +function command(executable, args, cwd, environment = env) { + return execFileSync(executable, args, { cwd, env: environment, encoding: "utf8", shell: process.platform === "win32" && executable.endsWith(".cmd"), maxBuffer: 16 * 1024 * 1024 }); +} +function pack(directory) { + const result = JSON.parse(command(npm, ["pack", "--ignore-scripts", "--json", "--pack-destination", root], directory)); + return join(root, result[0].filename); +} +function bytes(directory) { + let size = 0; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const path = join(directory, entry.name); + size += entry.isDirectory() ? bytes(path) : statSync(path).size; + } + return size; +} +try { + const platformTarball = pack(platform); + const wrapper = join(root, "wrapper"); + mkdirSync(wrapper); + cpSync(join(repository, "packages/cli/scripts"), join(wrapper, "scripts"), { recursive: true }); + const manifest = JSON.parse(readFileSync(join(repository, "packages/cli/package.json"), "utf8")); + assert.equal(manifest.version, identity.version); + assert.ok(Object.keys(manifest.optionalDependencies).every(name => name.startsWith("@scriptc/cli-")), "the default CLI must only depend on native host packages"); + manifest.optionalDependencies = Object.fromEntries(Object.keys(manifest.optionalDependencies).map(name => [name, name === identity.name ? "file:" + platformTarball : identity.version])); + delete manifest.devDependencies; + delete manifest.scripts.prepack; + writeFileSync(join(wrapper, "package.json"), JSON.stringify(manifest)); + prepareNativeCommand(wrapper); + const tarball = pack(wrapper); + const project = join(root, "project"); + mkdirSync(project); + command(npm, ["install", "--offline", "--no-audit", "--no-fund", tarball], project); + const executable = join(project, "node_modules/scriptc/bin/scriptc.exe"); + assert.equal(command(executable, ["--version"], project, { ...env, PATH: "" }).trim(), identity.version); + const entry = join(project, "hello.ts"); + writeFileSync(entry, 'console.log("Hello, world!");\n'); + const binary = join(project, process.platform === "win32" ? "hello.exe" : "hello"); + const compileEnv = { ...env, SCRIPTC_CACHE_DIR: join(root, "build-cache") }; + command(executable, ["build", entry, "-o", binary], project, compileEnv); + const result = spawnSync(binary, [], { cwd: project, env: { PATH: "" }, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Hello, world!\n"); + assert.equal(result.stderr, ""); + const helloBytes = statSync(binary).size; + writeFileSync(entry, 'import { randomBytes, randomUUID } from "node:crypto";\nconst bytes = randomBytes(32);\nconsole.log(bytes.length === 32, randomUUID().length === 36, Math.random() >= 0);\n'); + command(executable, ["build", entry, "-o", binary], project, compileEnv); + assert.equal(command(binary, [], project, { PATH: "" }), "true true true\n"); + const installed = readdirSync(join(project, "node_modules/@scriptc")); + assert.deepEqual(installed, [identity.name.slice("@scriptc/".length)]); + console.log(JSON.stringify({ package: identity.name, version: identity.version, installed_packages: ["scriptc", ...installed], installed_file_bytes: bytes(join(project, "node_modules")), hello_executable_bytes: helloBytes, crypto_executable_bytes: statSync(binary).size, output: result.stdout })); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-linux-abi.mjs b/scripts/verify-linux-abi.mjs new file mode 100644 index 000000000..fa287c8a7 --- /dev/null +++ b/scripts/verify-linux-abi.mjs @@ -0,0 +1,20 @@ +import { execFileSync } from "node:child_process"; +import { GLIBC_RUNTIME_FLOOR } from "../packages/runtime-pack-common/scripts/glibc-toolchain.mjs"; + +/** Audit every executable, including process helpers, before publishing. */ +export function verifyLinuxAbi(executable, { staticOnly = false } = {}) { + const headers = execFileSync("readelf", ["--wide", "--program-headers", executable], { encoding: "utf8" }); + if (staticOnly && /\bINTERP\b/.test(headers)) { + throw new Error(`${executable} must be a static executable`); + } + const versions = execFileSync("readelf", ["--wide", "--version-info", executable], { encoding: "utf8" }); + const [floorMajor, floorMinor] = GLIBC_RUNTIME_FLOOR.split(".").map(Number); + for (const match of versions.matchAll(/\bName: (GLIBC_[\w.]+)/g)) { + const version = /^GLIBC_(\d+)\.(\d+)(?:\.(\d+))?$/.exec(match[1]); + if (version === null) throw new Error(`${executable} requires unsupported glibc ABI ${match[1]}`); + const [, major, minor, patch] = version.map(Number); + if (major > floorMajor || (major === floorMajor && (minor > floorMinor || (minor === floorMinor && patch > 0)))) { + throw new Error(`${executable} requires ${match[0].slice(6)}; the supported baseline is glibc ${GLIBC_RUNTIME_FLOOR}`); + } + } +} diff --git a/scripts/verify-native-cli.mjs b/scripts/verify-native-cli.mjs index 1747f371b..2ece152c1 100644 --- a/scripts/verify-native-cli.mjs +++ b/scripts/verify-native-cli.mjs @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path"; +import { verifyLinuxAbi } from "./verify-linux-abi.mjs"; const root = realpathSync(resolve(process.argv[2] ?? ".")); const identity = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); @@ -47,6 +48,12 @@ for (const directory of [distribution, join(distribution, "lib/runtime-sources/v if (readdirSync(directory).some((name) => ["node_modules", ".cache", "seed"].includes(name))) throw new Error(`development files present in ${directory}`); } if (process.argv.includes("--run")) { + if (target.startsWith("linux-")) { + for (const executable of [binary, resolve(dirname(binary), toolchain.ts7), resolve(dirname(binary), toolchain.comptime)]) { + verifyLinuxAbi(executable, { staticOnly: target.endsWith("-musl") }); + } + verifyLinuxAbi(resolve(dirname(binary), toolchain.llvm_package, "bin/scriptc-llvm-codegen"), { staticOnly: true }); + } const version = execFileSync(binary, ["--version"], { encoding: "utf8", env: { ...process.env, PATH: "" } }).trim(); if (version !== identity.version) throw new Error(`native command reported ${version}, expected ${identity.version}`); } diff --git a/tests/harness/README.md b/tests/harness/README.md index 2c8fa951d..c278b1e5f 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -71,7 +71,9 @@ Full-suite runs (`vitest run` with no filters) take an advisory machine-wide loc ### Development build benchmark -After rebuilding the workspace, `pnpm bench:builds` measures the built CLI on a generated 17-module, 256-function TypeScript program using `--optimization=dev`. It reports one build with an empty scriptc cache, five exact rebuilds, and five rebuilds after changing an imported module. Every run uses a fresh private cache and verifies each binary's stdout, stderr, and exit status against Node, outside the timed build. Temporary files are removed on completion. `pnpm bench:builds --iterations=3` changes the sample count; progress goes to stderr and the JSON result, including individual samples and medians, goes to stdout (use `pnpm --silent bench:builds` when capturing JSON). +After rebuilding the workspace, `pnpm bench:builds` measures the built CLI on a generated 17-module, 256-function TypeScript program using `--optimization=dev`. It reports five builds with an empty scriptc cache, five exact rebuilds, and five rebuilds after changing an imported module. Every invocation uses a private cache and verifies each binary's stdout, stderr, and exit status against Node, outside the timed build. Temporary files are removed on completion. `pnpm bench:builds --iterations=3` changes the sample count; progress goes to stderr and the JSON result, including individual samples and medians, goes to stdout (use `pnpm --silent bench:builds` when capturing JSON). + +`--workload=log-summary` exercises a five-module request-log processor; `--workload=inventory-report` exercises a fifteen-module inventory report with parsing, validation, filtering, aggregation, and ranking. Both process 10,000 records and change an imported value that appears in the output for each edit. `--compiler=/path/to/scriptc` measures an installed native compiler. Results also include executable size and median end-to-end run time, including startup and application work. `--memory` adds peak process RSS from `/usr/bin/time` on macOS and Linux; this is not the sum of concurrent processes' memory. `--launches=20` controls the measured runs after three warmups. Empty-cache builds clear scriptc's cache, not the operating system's file cache. This is a local latency benchmark, not a timing assertion in CI. Compare the same Node version, target, compiler installation, and machine load; the empty-cache sample does not flush OS filesystem or Node bytecode caches. Unset `SCRIPTC_TARGET` because the benchmark executes the resulting host binary. Use representative application measurements alongside this small module-graph baseline when choosing further optimizations. diff --git a/tests/harness/native-cli-packaging.test.ts b/tests/harness/native-cli-packaging.test.ts index 481228451..1d3e40f8e 100644 --- a/tests/harness/native-cli-packaging.test.ts +++ b/tests/harness/native-cli-packaging.test.ts @@ -76,7 +76,8 @@ test.each([true, false])("verify and archive a native distribution across packag const manifest = JSON.parse(readFileSync(join(unpacked, "bin", f.executable + ".json"), "utf8")); expect(manifest.ts7).toBe(f.manifest.ts7); const runtime = manifest.runtime_packs[0]; - expect(JSON.parse(readFileSync(join(unpacked, "bin", runtime.path, "runtime-pack.json"), "utf8")).target.name).toBe(f.manifest.target); + expect(JSON.parse(readFileSync(join(unpacked, "bin", runtime.path.replaceAll("\\", "/"), "runtime-pack.json"), "utf8")).target.name).toBe(f.manifest.target); + expect(manifest.runtime_packs).toEqual(f.manifest.runtime_packs); expect(readFileSync(f.manifestPath)).toEqual(original); }); diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 1b63e7076..c52f902f7 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -1,5 +1,5 @@ import { execFile, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -45,6 +45,15 @@ test("the production CLI relocates, builds programs, and rebuilds itself with No // before the sanitizer can exercise the compiler. ...options, env: { ...process.env, SCRIPTC_SAN: sanitize ? "1" : "", SCRIPTC_NATIVE_EMIT_IR: "1" }, })); + // CI reuses this already-built seed for npm and older-libc installation + // smoke tests, avoiding another full compiler build on the critical path. + const packageDirectory = process.env["SCRIPTC_BOOTSTRAP_PACKAGE_DIR"]; + if (!sanitize && packageDirectory !== undefined) { + if (process.platform !== "linux" || process.arch !== "x64") throw new Error("bootstrap package export requires the Linux x64 GNU CI host"); + mkdirSync(packageDirectory, { recursive: true }); + cpSync(join(root, "packages/cli-linux-x64-gnu/package.json"), join(packageDirectory, "package.json")); + cpSync(distribution, join(packageDirectory, "dist"), { recursive: true }); + } // All compiler assets must survive moving the complete distribution. const relocated = join(directory, "relocated"); renameSync(distribution, relocated); @@ -162,9 +171,14 @@ test("the production CLI relocates, builds programs, and rebuilds itself with No const linker = join(directory, "zigcc"); writeFileSync(linker, `#!/bin/sh\nexec '${absoluteCommand("zig").replaceAll("'", "'\\''")}' cc "$@"\n`, { mode: 0o755 }); const output = join(directory, "three.wasm"); + // Install the cross-target pack after relocating the native compiler. + // Its resolver must discover project dependencies without Node on PATH. + const scope = join(directory, "node_modules/@scriptc"); + mkdirSync(scope, { recursive: true }); + symlinkSync(join(root, "packages/runtime-wasm32-wasi"), join(scope, "runtime-wasm32-wasi"), "dir"); const built = await exec(probe, ["build", "--lib", "--profile", threeProfile, "-o", output], { - ...nativeOptions, env: { ...nativeOptions.env, SCRIPTC_TARGET: "wasm32-wasi", SCRIPTC_LINKER: linker, - SCRIPTC_RUNTIME_PACK: join(root, "packages/runtime-wasm32-wasi"), SCRIPTC_NO_CACHE: "1" }, + ...nativeOptions, cwd: directory, env: { ...nativeOptions.env, SCRIPTC_TARGET: "wasm32-wasi", SCRIPTC_LINKER: linker, + SCRIPTC_RUNTIME_PACK: undefined, SCRIPTC_NO_CACHE: "1" }, }); expect(built.stdout.trim()).toBe(output); expect(comparableStderr(built.stderr)).toBe("");