diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 42f3f2065..e6360e915 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -63,6 +63,19 @@ headers from an arbitrary host LLVM install; the libcxx package generates and ships a version-matched header tree with its `libc++.a` and `libc++abi.a`. See `packages/registry/mariadb/build-mariadb.sh` for a complete example. +**Header-scanning build steps (cpp linemarkers + `-P`)**: Some language +runtimes discover constants at build time by preprocessing a system header and +scanning the output for `# "file"` linemarkers to learn which headers to +grep (e.g. Perl's `ext/Errno/Errno_pm.PL` finds the `E*` errno constants this +way). perl-cross defines `cpp`/`cpprun`/`cppstdin` as `"$cc -E -P"`, and `-P` +*suppresses* linemarkers — so on the wasm cross target the scan discovers zero +headers and silently produces nothing (Errno then dies "No error definitions +found" and `use Errno` fails, even though the constants are plain `#define E*` +in `$WASM_POSIX_SYSROOT/include/bits/errno.h`). When a `.PL`/config step relies +on cpp linemarkers, point it at the sysroot header directly rather than +depending on linemarker discovery. See the `Errno_pm.PL` fallback patch in +`packages/registry/perl/build-perl.sh`. + ### Step 3: Test it ```bash @@ -819,3 +832,20 @@ accepted. See [fork-instrumentation.md](fork-instrumentation.md). **Process hangs on read**: The fd might be in blocking mode waiting for data. Check that writers are properly closing their end of the pipe. **Browser SharedArrayBuffer unavailable**: Ensure COOP/COEP headers are set. In production, the service worker handles this. In dev, Vite's config sets them. + +**Locale panic / `setlocale(LC_ALL, "")` format mismatch**: Kandelo's libc is +musl, whose `setlocale(LC_ALL, "")` returns a *positional*, `;`-separated +composite of the per-category locales in category order +(`CTYPE;NUMERIC;TIME;COLLATE;MONETARY;MESSAGES`) when the categories differ — +e.g. with no locale env set it returns `C.UTF-8;C;C;C;C;C`. This is POSIX-legal +(the `LC_ALL` return string is unspecified and need only round-trip through +`setlocale`), but it is *not* glibc's `LC_CTYPE=…;LC_NUMERIC=…` `name=value` +form. A runtime that assumes the glibc format — often because a cross-build +tool defaulted to it — will mis-parse musl's output and can abort at startup. +Perl 5.40 hit exactly this: perl-cross configured the target with +`PERL_LC_ALL_USES_NAME_VALUE_PAIRS`, so perl parsed `C.UTF-8` as `name=value`, +found no `=`, and panicked (`packages/registry/perl/build-perl.sh` now patches +the target `config.h` to perl's positional mode with musl's category order; see +kd-dvph). If you port another locale-aware runtime and see startup failures +tied to `LC_ALL`, check whether it assumes the glibc composite format and point +it at musl's positional notation instead of forcing `LC_ALL=C`. diff --git a/homebrew/kandelo-homebrew/Formula/perl.rb b/homebrew/kandelo-homebrew/Formula/perl.rb new file mode 100644 index 000000000..d6f1ca037 --- /dev/null +++ b/homebrew/kandelo-homebrew/Formula/perl.rb @@ -0,0 +1,60 @@ +require_relative "../Kandelo/formula_support/kandelo_package" + +class Perl < Formula + include KandeloPackageFormula + SOURCE_URL = "https://www.cpan.org/src/5.0/perl-5.40.3.tar.gz" + SOURCE_SHA256 = "4c155b4e6160682b38919b55ac319081b898db11857cf18a7d9ffed2648ccaff" + PERL_PRIVLIB = "5.40.3" + + desc "Perl interpreter for Kandelo (with generated core-module runtime)" + homepage "https://www.perl.org/" + url SOURCE_URL + sha256 SOURCE_SHA256 + license any_of: ["Artistic-1.0-Perl", "GPL-1.0-or-later"] + + skip_clean "bin" + skip_clean "lib/perl5" + + def install + out_dir = kandelo_build_package("perl", "build-perl.sh", SOURCE_URL, SOURCE_SHA256, + script_env: { "PERL_VERSION" => version.to_s }) + kandelo_install_bin(out_dir, "perl.wasm", "perl") + + # Ship the generated core-module runtime library (XSLoader.pm, Config*.pm, + # File::Spec and the rest of the pure-perl core tree) so the runtime can + # load File::Spec (-> Cwd -> XSLoader); the bare perl.wasm carries none. + # build-perl.sh stages perl-src/lib via `make all` and zips it as + # perl-runtime.zip. The test's PERL5LIB (below) points at the installed + # lib/perl5/#{PERL_PRIVLIB}. + runtime_stage = buildpath/"perl-runtime-stage" + system "unzip", "-q", out_dir/"perl-runtime.zip", "-d", runtime_stage + (prefix/"lib").install Dir["#{runtime_stage}/lib/*"] + end + + test do + # LC_ALL=C: perl 5.40 panics at startup parsing the composite default + # locale Kandelo's musl setlocale returns ('C.UTF-8;C;C;C;C;C') -- a + # separate platform boundary (kd-dvph), not this package's gap. + env = { "PERL5LIB" => (lib/"perl5/#{PERL_PRIVLIB}").to_s, "LC_ALL" => "C" } + + # Interpreter smoke (the minimal strict/warnings arithmetic check). + assert_match "5", kandelo_run_wasm(bin/"perl", + ["-e", "use strict; use warnings; print 2 + 3"], env: env) + + # Regression guard for the reported gap (kd-k7zy): File::Spec must load and + # build the expected path, XSLoader.pm (the missing generated file) must + # load, and an XS core module must bootstrap through XSLoader::load. + prog = <<~PERL + use strict; use warnings; + use File::Spec; + use XSLoader; + use POSIX (); + my $p = File::Spec->catfile("a", "b", "c.txt"); + die "File::Spec catfile: $p" unless $p eq "a/b/c.txt"; + die "POSIX floor" unless POSIX::floor(3.7) == 3; + print "perl-runtime-ok xsloader=$XSLoader::VERSION"; + PERL + assert_match "perl-runtime-ok xsloader=", + kandelo_run_wasm(bin/"perl", ["-e", prog], env: env) + end +end diff --git a/packages/registry/perl/build-perl.sh b/packages/registry/perl/build-perl.sh index f42f31588..19d3124c3 100755 --- a/packages/registry/perl/build-perl.sh +++ b/packages/registry/perl/build-perl.sh @@ -13,17 +13,24 @@ set -euo pipefail # # Output: packages/registry/perl/bin/perl.wasm -PERL_VERSION="${PERL_VERSION:-5.40.3}" +PERL_VERSION="${WASM_POSIX_DEP_VERSION:-${PERL_VERSION:-5.40.3}}" PERL_CROSS_VERSION="${PERL_CROSS_VERSION:-1.6.4}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/perl-src" -BIN_DIR="$SCRIPT_DIR/bin" -SYSROOT="$REPO_ROOT/sysroot" +WORK_DIR="${WASM_POSIX_DEP_WORK_DIR:-$SCRIPT_DIR}" +SRC_DIR="$WORK_DIR/perl-src" +BIN_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/bin}" +# Worktree-local SDK on PATH (no global npm link required). +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" # --- Prerequisites --- if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 + echo "ERROR: wasm32posix-cc not found after sourcing sdk/activate.sh." >&2 exit 1 fi @@ -32,8 +39,6 @@ if [ ! -f "$SYSROOT/lib/libc.a" ]; then exit 1 fi -export WASM_POSIX_SYSROOT="$SYSROOT" - # perl-cross's configure scripts require GNU tools (sed -r, readelf, objdump). # scripts/dev-shell.sh provides those tools in the pure build environment. # LLVM provides readelf and objdump that perl-cross needs @@ -47,7 +52,7 @@ if [ -z "${LLVM_BIN:-}" ]; then fi if [ -d "$LLVM_BIN" ]; then # Create temp dir with readelf/objdump symlinks for perl-cross - TOOL_DIR="$SCRIPT_DIR/.host-tools" + TOOL_DIR="$WORK_DIR/.host-tools" mkdir -p "$TOOL_DIR" ln -sf "$LLVM_BIN/llvm-readelf" "$TOOL_DIR/readelf" ln -sf "$LLVM_BIN/llvm-objdump" "$TOOL_DIR/objdump" @@ -57,11 +62,14 @@ fi # --- Download Perl source + perl-cross overlay --- if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading Perl $PERL_VERSION..." - TARBALL="perl-${PERL_VERSION}.tar.gz" - URL="https://www.cpan.org/src/5.0/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + TARBALL="$(basename "$SOURCE_URL")" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "/tmp/$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 /tmp/$TARBALL" | shasum -a 256 -c - + fi mkdir -p "$SRC_DIR" - tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 + tar xf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 rm "/tmp/$TARBALL" echo "==> Downloading perl-cross $PERL_CROSS_VERSION..." @@ -217,6 +225,58 @@ PYEOF2 -e "s/whichprog readelf READELF readelf || die \"Cannot find readelf\"/whichprog readelf READELF readelf || true/" \ -e "s/whichprog objdump OBJDUMP objdump || die \"Cannot find objdump\"/whichprog objdump OBJDUMP objdump || true/" \ "$SRC_DIR/cnf/configure_tool.sh" + + # Point ext/Errno's errno-constant scan at the sysroot errno headers. + # Errno_pm.PL::get_files() discovers which headers define the E* constants + # by preprocessing `#include ` and scanning the output for + # `# "file"` linemarkers -- but perl-cross defines cpp/cpprun/ + # cppstdin as "$cc -E -P" (cnf/configure_tool.sh) and -P suppresses + # linemarkers, so on the wasm cross target the scan discovers zero headers, + # collects no constants, and Errno_pm.PL dies "No error definitions found". + # Errno.pm is then never generated/staged and `use Errno` fails. The + # constants exist as plain `#define E* ` in the sysroot (musl + # arch/generic bits/errno.h); patch get_files() to fall back to the sysroot + # errno headers when linemarker discovery yields nothing. + chmod u+w "$SRC_DIR/ext/Errno/Errno_pm.PL" + python3 - "$SRC_DIR/ext/Errno/Errno_pm.PL" << 'PYEOF3' +import sys + +path = sys.argv[1] +with open(path) as f: + content = f.read() + +marker = "kd-gtxa: sysroot errno-header fallback" +if marker in content: + print("Errno_pm.PL already patched for kd-gtxa", file=sys.stderr) + sys.exit(0) + +old = " return uniq(@file);" +new = """ # kd-gtxa: sysroot errno-header fallback. perl-cross defines + # cpp/cpprun/cppstdin as "$cc -E -P"; -P suppresses the `# "file"` + # linemarkers get_files() scans for, so on the wasm cross target the loop + # above discovers zero headers -> no E* constants collected -> Errno.pm is + # never generated and write_errno_pm() dies "No error definitions found". + # Point the scan at the sysroot errno headers directly (musl ships the + # constants as plain `#define E* ` there). Fallback-only: leaves the + # upstream linemarker discovery intact wherever it already works. + if (!@file) { + my $sysroot = $ENV{WASM_POSIX_SYSROOT} || $Config{sysroot} || ''; + push @file, grep { -f $_ } + "$sysroot/include/errno.h", "$sysroot/include/bits/errno.h"; + } + return uniq(@file);""" + +if old not in content: + print("ERROR: Errno_pm.PL anchor 'return uniq(@file);' not found " + "(perl layout changed?) -- refusing to ship perl without Errno", + file=sys.stderr) + sys.exit(1) + +content = content.replace(old, new, 1) +with open(path, "w") as f: + f.write(content) +print("Patched get_files() in ext/Errno/Errno_pm.PL (kd-gtxa sysroot errno fallback)") +PYEOF3 fi cd "$SRC_DIR" @@ -242,6 +302,32 @@ if [ ! -f config.sh ]; then # platform that builds perl with clang. (Adding it to HOSTCFLAGS # ensures the buildmini sub-configure inherits it — perl-cross # propagates HOSTCFLAGS into the host CC invocation.) + # + # The SAME UB miscompile hits the TARGET perl.wasm, not just the host + # miniperl: with `-Doptimize=-O2` and no `-fno-strict-aliasing` in the + # target `-Dccflags`, perl.wasm panics `magic_killbackrefs` / + # `del_backref` the first time a loaded module traverses weak refs (e.g. + # `use File::Spec`, `use Config`, `use Data::Dumper`). That is why the + # earlier port only passed a trivial arithmetic smoke. `-Dccflags` below + # carries `-fno-strict-aliasing` so the shipped interpreter is correct. + # + # `-Dosname=linux` (below): the wasm32-unknown-none target left osname + # empty, so ExtUtils::MakeMaker's `$Config{osname} eq ...` probes hit + # undef and every core module's Makefile.PL/pm_to_blib staging failed + # (no generated runtime files). Kandelo presents a POSIX/linux-like + # syscall surface, so a linux osname routes MakeMaker through MM_Unix + # correctly and lets the core-module runtime tree generate + stage. + # + # `-Uusedl` (static extensions): Kandelo wasm has no working dlopen + # (dlerror() is a stub -> "Can't load Cwd.so ... dlerror() not + # implemented"). The default usedl=define builds each XS core module as a + # .so that the runtime can never load, so File::Spec (-> File::Spec::Unix + # -> Cwd, an XS module), POSIX, Fcntl, List::Util, etc. all fail. Building + # extensions statically links their XS into perl.wasm with a boot table so + # XSLoader::load resolves them without dlopen. The set of statically-linked + # extensions is curated after configure by editing Makefile.config's + # fullpath_static_ext (perl-cross has no -Dnoextensions handler); see that + # patch below for which extensions are dropped and why. export HOSTCFLAGS="-Wno-format -fno-strict-aliasing" # perl-cross's `--mode=cross` spawns two sub-configures: one for @@ -266,7 +352,8 @@ if [ ! -f config.sh ]; then -Dranlib=wasm32posix-ranlib \ -Dnm=wasm32posix-nm \ -Doptimize="-O2" \ - -Dccflags="-D_GNU_SOURCE -DNO_ENV_ARRAY_IN_MAIN -fvisibility=default" \ + -Dosname=linux \ + -Dccflags="-D_GNU_SOURCE -DNO_ENV_ARRAY_IN_MAIN -fvisibility=default -fno-strict-aliasing" \ -Dldflags="" \ -Dlddlflags="" \ -Dccdlflags="" \ @@ -279,6 +366,7 @@ if [ ! -f config.sh ]; then -Uuselargefiles \ -Duse64bitint \ -Duseperlio \ + -Uusedl \ \ -Dcharsize=1 \ -Dshortsize=2 \ @@ -463,7 +551,7 @@ if [ ! -f config.sh ]; then -Dd_crypt=undef \ -Dd_times=undef \ -Dd_system=undef \ - 2>&1 | tee "$SCRIPT_DIR/configure.log" | tail -50 + 2>&1 | tee "$WORK_DIR/configure.log" | tail -50 echo "==> Configure complete." @@ -489,6 +577,21 @@ if [ ! -f config.sh ]; then -e "/^CFLAGS = /s/$/ -fvisibility=default -DNO_ENV_ARRAY_IN_MAIN/" \ Makefile.config + # Curate the static extension set (perl-cross ignores -Dnoextensions, so we + # edit fullpath_static_ext directly). Two reasons: + # - ext/re recompiles regcomp.c with -DPERL_EXT_RE_BUILD, whose symbols + # (Perl_reg_add_data, ...) collide with core regcomp.o at the static + # perl link ("duplicate symbol"). re is a debug pragma; drop it. + # - drop extensions whose XS needs libraries absent from the wasm sysroot + # (Compress::Raw::Zlib/Bzip2, Encode, Sys::Syslog, I18N::Langinfo, + # NDBM_File, Unicode::Collate/Normalize, PerlIO::encoding) or threads + # (we build -Uusethreads); with --allow-undefined those would link but + # add wasm imports the host cannot satisfy. Kept set uses standard + # libc/syscalls the kernel provides. Excluded modules -> follow-up. + KEEP_STATIC_EXT="ext/B ext/Devel-Peek ext/Fcntl ext/File-DosGlob ext/File-Glob ext/Hash-Util ext/Hash-Util-FieldHash ext/Opcode ext/POSIX ext/PerlIO-mmap ext/PerlIO-via ext/SDBM_File ext/Sys-Hostname ext/attributes ext/mro cpan/Digest-MD5 cpan/Digest-SHA cpan/Filter-Util-Call cpan/MIME-Base64 cpan/Math-BigInt-FastCalc cpan/Scalar-List-Utils cpan/Socket cpan/Time-Piece dist/Data-Dumper dist/Devel-PPPort dist/IO dist/PathTools dist/Storable dist/Time-HiRes" + sed -i.bak3 "s|^fullpath_static_ext = .*|fullpath_static_ext = ${KEEP_STATIC_EXT}|" Makefile.config + echo "==> Curated fullpath_static_ext to $(echo "$KEEP_STATIC_EXT" | wc -w | tr -d ' ') extensions (dropped re + external-lib/threads)." + # Patch Makefile for wasm32 linking: # Our toolchain uses --allow-undefined which creates env.* imports for unresolved # symbols instead of linking to definitions from other .o files. Combined with @@ -503,9 +606,81 @@ if [ ! -f config.sh ]; then Makefile fi +# --- Fix the default-locale startup panic (kd-dvph) --- +# +# musl's setlocale(LC_ALL,"") returns a POSITIONAL, ';'-separated composite of +# the per-category locale names in musl category order +# (CTYPE;NUMERIC;TIME;COLLATE;MONETARY;MESSAGES) whenever the categories are not +# all identical -- e.g. with no locale env set it returns the default +# "C.UTF-8;C;C;C;C;C" (musl gives LC_CTYPE a UTF-8 C locale, the rest plain C). +# This is POSIX-legal: the LC_ALL return string is unspecified and need only +# round-trip through setlocale, which musl's does. +# +# perl-cross cross-configures the TARGET with glibc's assumption +# (d_perl_lc_all_uses_name_value_pairs=define), so perl compiles out its +# positional LC_ALL parser and parses musl's first field "C.UTF-8" as a glibc +# "name=value" pair. There is no '=', so perl panics during the startup locale +# scan (locale.c "needs an '=' to split name=value") and aborts with exit 29. +# The effect is that perl is unusable unless LC_ALL is forced to a single value +# like "C" -- even LC_ALL=C.UTF-8 or LANG=... trips it. +# +# Fix the TARGET config header. In perl-cross, config.h is the primary +# (cross-compiled perl.wasm) config -- Makefile builds target objects with +# `%$o: %.c config.h` using the cross compiler -- while xconfig.h configures the +# build-time miniperl that runs on the build machine (`%$O: %.c xconfig.h`, +# HOSTCC). So patch config.h (target) and leave xconfig.h (host miniperl) +# matching the build machine's libc. +# This is the mechanism perl already uses for positional platforms (*BSD): +# - undef PERL_LC_ALL_USES_NAME_VALUE_PAIRS (musl is positional, not name=value) +# - define PERL_LC_ALL_SEPARATOR ";" +# - define PERL_LC_ALL_CATEGORY_POSITIONS_INIT to the LC_* categories in +# musl's emit order; perl's get_category_index() maps each to its internal +# index and a compile-time STATIC_ASSERT checks the count == LC_ALL_INDEX_. +# Idempotent + applied on every build so incremental rebuilds can't ship the +# unpatched interpreter. +echo "==> Patching config.h for musl positional LC_ALL notation (kd-dvph)..." +python3 - "$SRC_DIR/config.h" << 'PYLOCALE' +import re, sys + +path = sys.argv[1] +with open(path) as f: + original = f.read() + +def sub_macro(content, macro, replacement): + # Rewrite the single '#define'/'#undef' line for `macro` (whether currently + # defined or emitted as the commented "/*#define MACRO */" template form), + # leaving the doc-comment header (which has no #define/#undef) untouched. + pat = re.compile(r'^.*#\s*(?:define|undef)\s+' + re.escape(macro) + r'\b.*$', re.M) + new, n = pat.subn(lambda _m: replacement, content) + if n != 1: + # Fail loudly rather than ship a perl that mis-parses musl's LC_ALL: a + # future perl-cross template change that renamed/duplicated the macro + # line would otherwise silently skip the fix (locale.c would fall back + # to the all-categories-map-to-0 branch -> subtly wrong parsing). + sys.stderr.write("kd-dvph ERROR: %s matched %d config.h lines (expected 1); " + "perl-cross layout changed -- update the LC_ALL patch.\n" % (macro, n)) + sys.exit(1) + return new + +content = original +content = sub_macro(content, "PERL_LC_ALL_USES_NAME_VALUE_PAIRS", + "#undef PERL_LC_ALL_USES_NAME_VALUE_PAIRS\t/* kd-dvph: musl uses positional LC_ALL */") +content = sub_macro(content, "PERL_LC_ALL_SEPARATOR", + '#define PERL_LC_ALL_SEPARATOR ";"\t/**/') +content = sub_macro(content, "PERL_LC_ALL_CATEGORY_POSITIONS_INIT", + "#define PERL_LC_ALL_CATEGORY_POSITIONS_INIT { LC_CTYPE, LC_NUMERIC, LC_TIME, LC_COLLATE, LC_MONETARY, LC_MESSAGES }\t/**/") + +if content != original: + with open(path, 'w') as f: + f.write(content) + print("Patched config.h for musl positional LC_ALL notation (kd-dvph)") +else: + print("config.h already patched for musl positional LC_ALL notation (kd-dvph)") +PYLOCALE + # --- Build --- echo "==> Building Perl (this takes a while)..." -make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" perl 2>&1 | tee "$SCRIPT_DIR/build.log" | tail -80 +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" perl 2>&1 | tee "$WORK_DIR/build.log" | tail -80 echo "==> Collecting binary..." mkdir -p "$BIN_DIR" @@ -517,7 +692,7 @@ if [ -f "$SRC_DIR/perl" ]; then else echo "ERROR: perl binary not found after build" >&2 echo "==> Last 100 lines of build.log:" - tail -100 "$SCRIPT_DIR/build.log" + tail -100 "$WORK_DIR/build.log" exit 1 fi @@ -525,7 +700,97 @@ echo "" echo "==> Perl $PERL_VERSION built successfully!" echo "Binary: $BIN_DIR/perl.wasm" +# --- Build + package the generated core-module runtime library --- +# `make perl` links the interpreter but STOPS before perl-cross's +# `nonxs_ext extensions pods` sub-targets. Those sub-targets are what +# GENERATE core runtime files (e.g. lib/XSLoader.pm from +# dist/XSLoader/XSLoader_pm.PL) and stage the pure-perl core-module tree +# into perl-src/lib/ via pm_to_blib. Without them the shipped bottle has no +# XSLoader.pm, so loading File::Spec (-> Cwd -> XSLoader) fails at runtime -- +# the reported gap. These steps run under miniperl (the host build perl), so +# they need no wasm-target execution. +# +# We run `make -k` (keep-going) rather than a plain `make` because two known +# wasm boundaries make the FULL build return non-zero, and neither blocks the +# runtime this package ships (the curated static extensions above still build +# and their .pm still stage): +# 1. ext/Errno: Errno_pm.PL's errno-constant extraction finds none under the +# wasm sysroot ("No error definitions found") -> follow-up kd-gtxa. +# 2. The extensions dropped from fullpath_static_ext (external-lib/threads/re) +# have their Makefile.PL/pm_to_blib skipped -> follow-up kd-14n8. The +# curated static set (POSIX/Fcntl/Cwd/List::Util/...) is linked into +# perl.wasm and their pure-perl .pm stage via pm_to_blib, so File::Spec +# (-> Cwd XS), POSIX, etc. load at runtime without dlopen. +# We then verify the required generated files exist and package perl-src/lib/ +# (the staged privlib) as the perl-runtime.zip output that the Homebrew +# formula installs + points PERL5LIB at, and that the resolver ships alongside +# perl.wasm. +echo "==> Building + staging Perl runtime modules (make -k)..." +set +e +make -k -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" 2>&1 | tee "$WORK_DIR/build-all.log" | tail -40 +ALL_RC=${PIPESTATUS[0]} +set -e +if [ "$ALL_RC" -ne 0 ]; then + echo "==> 'make -k' exited $ALL_RC (expected: Errno + dropped external-lib exts);" >&2 + echo " verifying the required generated runtime files were still staged below." >&2 +fi + +# Stage the statically-linked extensions' .pm into lib/. perl-cross's static +# recipe runs `make -C ... static` (builds the .a) but never runs the +# module's pm_to_blib, and it touches a `/pm_to_blib` stamp so a later +# `make pm_to_blib` no-ops -- so File::Spec/POSIX/Cwd .pm never reach lib/ even +# though their XS is linked into perl.wasm. Remove the stamp and run each +# curated static ext's pm_to_blib (uses miniperl, no wasm-target execution). +echo "==> Staging static-extension .pm (pm_to_blib)..." +STATIC_EXT_DIRS="$(sed -n 's/^fullpath_static_ext = //p' Makefile.config)" +for d in $STATIC_EXT_DIRS; do + [ -d "$d" ] || continue + rm -f "$d/pm_to_blib" + make -C "$d" PERL_CORE=1 LIBPERL=libperl.a pm_to_blib >/dev/null 2>&1 || \ + echo " WARN: pm_to_blib failed for $d (checked by the post-check below)" >&2 +done + +PRIVLIB_SRC="$SRC_DIR/lib" +# Fail loudly if the generated core runtime files this package exists to ship +# are still absent -- do not publish a silently-incomplete runtime. Cwd.pm is +# the file whose absence made File::Spec fail in the original report. +missing="" +for f in XSLoader.pm Config.pm File/Spec.pm File/Spec/Unix.pm Cwd.pm Errno.pm; do + [ -f "$PRIVLIB_SRC/$f" ] || missing="$missing $f" +done +if [ -n "$missing" ]; then + echo "ERROR: required generated runtime files missing from $PRIVLIB_SRC:$missing" >&2 + echo " (make -k rc=$ALL_RC) -- see $WORK_DIR/build-all.log" >&2 + exit 1 +fi +echo "==> Generated runtime files present (XSLoader.pm, Config.pm, File::Spec, Cwd.pm, Errno.pm)." + +# `make perl` above linked perl.wasm before any extension was built; with +# `-Uusedl` the `make -k` pass compiles the core XS extensions and relinks +# perl.wasm to statically embed them (with the XSLoader boot table). Re-collect +# that interpreter -- it is the one that can load Cwd/POSIX/Fcntl without +# dlopen. (No-op for a usedl=define build where make -k does not relink perl.) +if [ -f "$SRC_DIR/perl" ]; then + cp "$SRC_DIR/perl" "$BIN_DIR/perl.wasm" + echo "==> Re-collected perl.wasm after make -k ($(wc -c < "$BIN_DIR/perl.wasm" | tr -d ' ') bytes)." +fi + +echo "==> Packaging Perl runtime library (lib/perl5/$PERL_VERSION)..." +RUNTIME_STAGE="$WORK_DIR/perl-runtime-stage" +rm -rf "$RUNTIME_STAGE" +mkdir -p "$RUNTIME_STAGE/lib/perl5/$PERL_VERSION" +# Ship the staged privlib. Keep unicore/ (utf8 + regex need it) and the +# generated *.pm/*.pl; drop *.bak left by the configure sed patches and the +# *.orig backups so the runtime zip carries only real library files. +cp -R "$PRIVLIB_SRC/." "$RUNTIME_STAGE/lib/perl5/$PERL_VERSION/" +find "$RUNTIME_STAGE" -type f \( -name '*.bak' -o -name '*.orig' \) -delete 2>/dev/null || true +RUNTIME_ZIP="$BIN_DIR/perl-runtime.zip" +rm -f "$RUNTIME_ZIP" +( cd "$RUNTIME_STAGE" && zip -q -r -X "$RUNTIME_ZIP" lib ) +echo "==> Packaged runtime: $RUNTIME_ZIP ($(du -h "$RUNTIME_ZIP" | cut -f1))" + # Install into local-binaries/ so the resolver picks the freshly-built -# binary over the fetched release. +# binary + runtime over the fetched release. source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary perl "$SCRIPT_DIR/bin/perl.wasm" +install_local_binary perl "$BIN_DIR/perl.wasm" +install_local_binary perl "$RUNTIME_ZIP" diff --git a/packages/registry/perl/build.toml b/packages/registry/perl/build.toml index 5d344dba6..50052bf67 100644 --- a/packages/registry/perl/build.toml +++ b/packages/registry/perl/build.toml @@ -1,7 +1,11 @@ script_path = "packages/registry/perl/build-perl.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 1 +# rev4: build-perl.sh patches the target config.h to musl's positional LC_ALL +# notation, fixing the default-locale startup panic (kd-dvph). Output bytes +# change (locale.o + relink), so the archive cache must invalidate. Stacked +# after kd-k7zy runtime (rev2, #821) and kd-gtxa Errno (rev3, #827). +revision = 4 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/perl/demo/errno-browser-smoke.ts b/packages/registry/perl/demo/errno-browser-smoke.ts new file mode 100644 index 000000000..710a83beb --- /dev/null +++ b/packages/registry/perl/demo/errno-browser-smoke.ts @@ -0,0 +1,150 @@ +/** + * errno-browser-smoke.ts — kd-gtxa Perl `use Errno` smoke in a real browser + * (headless Chromium via Playwright + the browser-demos test-runner page, which + * drives BrowserKernel). + * + * Confirms the fix is host-agnostic: the same perl.wasm + generated Errno.pm + * that pass under the Node kernel host also load under the browser kernel host. + * Errno.pm is pure-perl constants; loading it is an @INC VFS file-read, the + * identical path that loads Config/File::Spec/POSIX under both hosts. Here we + * inject the minimal `use Errno` load-closure (empirically Errno.pm, Config.pm, + * Config_heavy.pl, Exporter.pm, strict.pm, warnings.pm -- all flat in + * perl-src/lib) into a PERL5LIB dir in the browser VFS and run the same + * constant/tie assertions as the Node smoke. + * + * Runs the real thing when the browser asset bundle is present; otherwise + * SKIPS with a reason (exit 0) rather than failing -- the full browser stdlib + * bundle (kernel + dash/coreutils/grep/sed/gencat + perl.vfs) is tracked by + * kd-yuef, the same boundary #821 defers browser/bottle acceptance to. + * + * Usage (needs playwright + a chromium build available): + * npx tsx packages/registry/perl/demo/errno-browser-smoke.ts + */ +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; + +const scriptDir = dirname(new URL(import.meta.url).pathname); +const repoRoot = resolve(scriptDir, "../../../.."); +const BROWSER_DIR = resolve(repoRoot, "apps/browser-demos"); +const LIB = resolve(repoRoot, "packages/registry/perl/perl-src/lib"); +const VITE_PORT = 5208; + +// The empirically-determined `use Errno` load closure (via %INC under Node). +// All flat in perl-src/lib; injected into /plib and reached via PERL5LIB=/plib. +const CLOSURE = [ + "Errno.pm", "Config.pm", "Config_heavy.pl", + "Exporter.pm", "strict.pm", "warnings.pm", +]; + +// Assertions mirror the Node smoke (exact musl values + the %! tie). Constants +// are called via dynamic method dispatch (Errno->$name()) so there is no +// compile-time "called too early to check prototype" warning. In JS +// double-quoted strings $ and @ are literal; only " and perl's \n are escaped. +const PROG = [ + "use strict; no warnings 'prototype';", + "require Errno;", + "my @r;", + "push @r, 'use_Errno=' . ($INC{'Errno.pm'} ? 'ok' : 'FAIL');", + "for my $p ([EPERM=>1],[ENOENT=>2],[EACCES=>13],[EINVAL=>22],[EAGAIN=>11]) {", + " my ($n,$v) = @$p; my $g = Errno->can($n) ? Errno->$n() : -1;", + " push @r, \"$n=\" . ($g==$v ? 'ok' : \"FAIL($g)\");", + "}", + "my $c = grep { Errno->can($_) } @Errno::EXPORT_OK;", + "push @r, 'count=' . ($c>=100 ? 'ok' : \"FAIL($c)\");", + "{ local $! = Errno::ENOENT(); push @r, 'tie=' . (($!{ENOENT} && !$!{EACCES}) ? 'ok' : 'FAIL'); }", + "print 'RESULTS=', join(',', @r), \"\\n\";", + "print((grep { $_ !~ /=ok$/ } @r) ? \"PERL_ERRNO_BROWSER_SMOKE_FAIL\\n\" : \"PERL_ERRNO_BROWSER_SMOKE_PASS\\n\");", +].join("\n"); + +function skip(reason: string): never { + console.log(`PERL_ERRNO_BROWSER_SMOKE_SKIP: ${reason}`); + process.exit(0); +} + +function startVite(): Promise { + return new Promise((resolvePromise, reject) => { + const proc = spawn( + "npx", + ["vite", "--config", resolve(BROWSER_DIR, "vite.config.ts"), "--port", String(VITE_PORT)], + { cwd: BROWSER_DIR, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } }, + ); + let started = false; + const timer = setTimeout(() => { if (!started) { proc.kill(); reject(new Error("Vite did not start in 60s")); } }, 60_000); + proc.stdout!.on("data", (d: Buffer) => { + if (!started && d.toString().includes("Local:")) { + started = true; clearTimeout(timer); setTimeout(() => resolvePromise(proc), 500); + } + }); + proc.on("exit", (code) => { if (!started) { clearTimeout(timer); reject(new Error(`Vite exited ${code}`)); } }); + }); +} + +async function main() { + const perlWasm = resolve(repoRoot, "packages/registry/perl/bin/perl.wasm"); + if (!existsSync(perlWasm)) skip("perl.wasm not built (run build-perl.sh)"); + const missing = CLOSURE.filter((f) => !existsSync(resolve(LIB, f))); + if (missing.length) skip(`perl runtime lib missing ${missing.join(",")} (run build-perl.sh)`); + + // Playwright + chromium are optional in some environments. + let chromium: typeof import("playwright").chromium; + try { + ({ chromium } = await import("playwright")); + } catch { + skip("playwright not installed"); + } + + const perlBytes = readFileSync(perlWasm); + const dataFiles = CLOSURE.map((f) => ({ + path: `/plib/${f}`, + data: Array.from(readFileSync(resolve(LIB, f))), + })); + + let vite: ChildProcess | undefined; + let browser: Awaited> | undefined; + try { + try { + vite = await startVite(); + } catch (e) { + skip(`vite dev server unavailable: ${String(e)}`); + } + try { + browser = await chromium.launch(); + } catch (e) { + skip(`chromium unavailable: ${String(e)}`); + } + const page = await browser!.newPage(); + await page.goto(`http://localhost:${VITE_PORT}/pages/test-runner/`); + try { + await page.waitForFunction(() => (window as any).__testRunnerReady === true, {}, { timeout: 60_000 }); + } catch { + skip("test-runner did not initialize (browser asset bundle incomplete: kernel/dash/coreutils/grep/sed/gencat wasm) -- tracked by kd-yuef"); + } + + const r: any = await page.evaluate( + async ({ bytes, argv, env, files }) => { + const ab = new Uint8Array(bytes).buffer; + return await (window as any).__runTest(ab, argv, 60_000, { env, dataFiles: files }); + }, + { + bytes: Array.from(perlBytes), + argv: ["perl", "-e", PROG], + env: ["PERL5LIB=/plib", "LC_ALL=C", "HOME=/tmp", "TMPDIR=/tmp"], + files: dataFiles, + }, + ); + + const stdout = (r.stdout || "").trim(); + console.log(`exit=${r.exitCode}`); + console.log(stdout); + if (r.stderr) console.log("STDERR:", r.stderr.trim()); + const ok = r.exitCode === 0 && stdout.includes("PERL_ERRNO_BROWSER_SMOKE_PASS"); + console.log(ok ? "BROWSER_SMOKE_OK" : "BROWSER_SMOKE_FAIL"); + process.exit(ok ? 0 : 1); + } finally { + if (browser) await browser.close(); + if (vite) vite.kill(); + } +} + +main().catch((err) => { console.error("Fatal error:", err); process.exit(1); }); diff --git a/packages/registry/perl/demo/errno-smoke.ts b/packages/registry/perl/demo/errno-smoke.ts new file mode 100644 index 000000000..37e044518 --- /dev/null +++ b/packages/registry/perl/demo/errno-smoke.ts @@ -0,0 +1,108 @@ +/** + * errno-smoke.ts — kd-gtxa Perl Errno.pm runtime smoke on Kandelo. + * + * Runs the built perl.wasm under the Node kernel host (host-fs passthrough so + * PERL5LIB resolves) and checks that `use Errno` loads and exposes the wasm + * target's errno constants with musl's numeric values. + * + * Guards the reported gap (kd-gtxa): ext/Errno's Errno_pm.PL discovers which + * headers hold the E* #defines by preprocessing `#include ` and + * scanning the output for `# "file"` linemarkers. The wasm target's + * cpp config runs the preprocessor with -P, which inhibits linemarkers, so the + * scan found no headers, collected no constants, and Errno_pm.PL died + * "No error definitions found" -> Errno.pm was never generated/staged and + * `use Errno` failed "Can't locate Errno.pm". The constants themselves are + * plain `#define EPERM 1` in the sysroot (musl arch/generic bits/errno.h); + * they were simply never discovered. build-perl.sh now points the scan at the + * sysroot errno headers directly so Errno.pm generates and ships. + * + * Usage: + * bash build.sh && bash packages/registry/perl/build-perl.sh + * npx tsx packages/registry/perl/demo/errno-smoke.ts [PERL5LIB_DIR] + * + * PERL5LIB_DIR defaults to the built privlib staged by build-perl.sh; pass an + * arg (e.g. an unzipped perl-runtime.zip) to smoke the shippable layout. + */ +import { existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { NodePlatformIO } from "../../../../host/src/platform/node"; + +const scriptDir = dirname(new URL(import.meta.url).pathname); +const repoRoot = resolve(scriptDir, "../../../.."); + +// Known musl (arch/generic bits/errno.h) values. Asserting exact numbers proves +// Errno.pm carries the *wasm target's* constants, not the build host's. +const EXPECT: Array<[string, number]> = [ + ["EPERM", 1], + ["ENOENT", 2], + ["ESRCH", 3], + ["EINTR", 4], + ["EIO", 5], + ["EBADF", 9], + ["EAGAIN", 11], + ["ENOMEM", 12], + ["EACCES", 13], + ["EEXIST", 17], + ["ENOTDIR", 20], + ["EISDIR", 21], + ["EINVAL", 22], +]; + +const checks = EXPECT.map( + ([n, v]) => + `ck('${n}', sub { Errno::${n}() == ${v} or die 'got '.Errno::${n}() });`, +).join("\n"); + +const PROG = [ + "use strict; use warnings;", + // Errno's constant subs are require'd at runtime, so calling them in this + // same -e is 'too early to check prototype' -- a benign parse-time warning. + "no warnings 'prototype';", + "my @res;", + "sub ck { my ($n,$c)=@_; my $r=eval { $c->() }; push @res, $n.'='.((defined $r && !$@)?'ok':'FAIL('.(($@=~/^(.*?)(?: at |\\n)/)?$1:'err').')'); }", + // The reported gap: Errno.pm must exist and load at all. + "ck('use_Errno', sub { require Errno; $INC{'Errno.pm'} or die 'not loaded'; 1 });", + // Exact musl values for a spread of constants. + checks, + // Enough constants present (musl generic ships 134 E*); a truncated scan + // would collect far fewer, so require a healthy count. + "ck('count>=100', sub { my $n=grep { Errno->can($_) } @Errno::EXPORT_OK; $n>=100 or die \"only $n\" });", + // The tied %! interface (Errno's headline feature) must reflect $!. + "ck('errno_tie', sub { local $! = Errno::ENOENT(); $!{ENOENT} or die; !$!{EACCES} or die 'EACCES leaked'; 1 });", + "print 'PERLVER=',$],\"\\n\";", + "print 'ERRNO_COUNT=',scalar(grep { Errno->can($_) } @Errno::EXPORT_OK),\"\\n\";", + "print 'RESULTS=',join(',',@res),\"\\n\";", + "print((grep { !/=ok$/ } @res) ? \"PERL_ERRNO_SMOKE_FAIL\\n\" : \"PERL_ERRNO_SMOKE_PASS\\n\");", +].join("\n"); + +async function main() { + const perlWasm = resolve(repoRoot, "packages/registry/perl/bin/perl.wasm"); + const perl5lib = process.argv[2] || + resolve(repoRoot, "packages/registry/perl/perl-src/lib"); + if (!existsSync(perlWasm)) { + console.error("perl.wasm not found. Run: bash packages/registry/perl/build-perl.sh"); + process.exit(1); + } + + const result = await runCentralizedProgram({ + programPath: perlWasm, + argv: ["perl", "-e", PROG], + // LC_ALL=C: perl 5.40 panics at startup parsing the composite default + // locale Kandelo's musl setlocale returns ('C.UTF-8;C;C;C;C;C') -- a + // separate platform boundary (kd-dvph), not this package's gap. + env: [`PERL5LIB=${perl5lib}`, `LC_ALL=C`, `HOME=/tmp`, `TMPDIR=/tmp`], + io: new NodePlatformIO(), + timeout: 300_000, + }); + + process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + const ok = result.exitCode === 0 && result.stdout.includes("PERL_ERRNO_SMOKE_PASS"); + process.exit(ok ? 0 : (result.exitCode || 1)); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/registry/perl/demo/locale-browser-smoke.ts b/packages/registry/perl/demo/locale-browser-smoke.ts new file mode 100644 index 000000000..fecdc907a --- /dev/null +++ b/packages/registry/perl/demo/locale-browser-smoke.ts @@ -0,0 +1,105 @@ +/** + * locale-browser-smoke.ts — kd-dvph Perl default-locale startup smoke in a real + * browser (headless Chromium via Playwright + the browser-demos test-runner + * page, which drives BrowserKernel). + * + * Confirms the fix is host-agnostic: the same perl.wasm that no longer panics + * under the Node kernel host also starts cleanly under the browser kernel host + * with the browser's default locale env. Runs `perl -e 'print "R=",2+3'` with: + * - no locale env (the disparate musl composite that used to panic), and + * - LANG=en_US.UTF-8 (the exact env live-setup.ts / browser-kernel-host.ts + * pass in the browser UI). + * + * Usage (needs playwright + a chromium build available): + * npx tsx packages/registry/perl/demo/locale-browser-smoke.ts + */ +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { chromium, type Browser, type Page } from "playwright"; + +const scriptDir = dirname(new URL(import.meta.url).pathname); +const repoRoot = resolve(scriptDir, "../../../.."); +const BROWSER_DIR = resolve(repoRoot, "apps/browser-demos"); +const VITE_PORT = 5207; +const ARITH = 'print "R=", 2 + 3, "\\n"'; + +interface Case { name: string; env: string[]; } +const CASES: Case[] = [ + { name: "unset-locale (no LC_ALL/LANG)", env: [] }, + { name: "LANG=en_US.UTF-8 (browser default)", env: ["LANG=en_US.UTF-8"] }, +]; + +function startVite(): Promise { + return new Promise((resolvePromise, reject) => { + const proc = spawn( + "npx", + ["vite", "--config", resolve(BROWSER_DIR, "vite.config.ts"), "--port", String(VITE_PORT)], + { cwd: BROWSER_DIR, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } }, + ); + let started = false; + const timer = setTimeout(() => { if (!started) { proc.kill(); reject(new Error("Vite did not start in 60s")); } }, 60_000); + proc.stdout!.on("data", (d: Buffer) => { + if (!started && d.toString().includes("Local:")) { + started = true; clearTimeout(timer); setTimeout(() => resolvePromise(proc), 500); + } + }); + proc.on("exit", (code) => { if (!started) { clearTimeout(timer); reject(new Error(`Vite exited ${code}`)); } }); + }); +} + +async function runCase(page: Page, perlBytes: Buffer, c: Case) { + return page.evaluate( + async ({ bytes, argv, env }) => { + const ab = new Uint8Array(bytes).buffer; + return await (window as any).__runTest(ab, argv, 60_000, { env }); + }, + { bytes: Array.from(perlBytes), argv: ["perl", "-e", ARITH], env: c.env }, + ); +} + +async function main() { + const perlWasm = resolve(repoRoot, "packages/registry/perl/bin/perl.wasm"); + if (!existsSync(perlWasm)) { + console.error("perl.wasm not found. Run: bash packages/registry/perl/build-perl.sh"); + process.exit(1); + } + const perlBytes = readFileSync(perlWasm); + + let vite: ChildProcess | undefined; + let browser: Browser | undefined; + const passed: string[] = []; + const failed: string[] = []; + try { + vite = await startVite(); + browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto(`http://localhost:${VITE_PORT}/pages/test-runner/`); + await page.waitForFunction(() => (window as any).__testRunnerReady === true, {}, { timeout: 60_000 }); + + for (const c of CASES) { + let r: any; + try { + r = await runCase(page, perlBytes, c); + } catch (err) { + failed.push(`${c.name}: threw ${String(err)}`); + continue; + } + const ok = r.exitCode === 0 && (r.stdout || "").includes("R=5"); + const detail = `exit=${r.exitCode} stdout=${JSON.stringify((r.stdout || "").trim())} stderr=${JSON.stringify((r.stderr || "").trim())}`; + (ok ? passed : failed).push(`${c.name}: ${detail}`); + } + } finally { + if (browser) await browser.close(); + if (vite) vite.kill(); + } + + console.log("=== PASSED (browser) ==="); + for (const p of passed) console.log(" " + p); + if (failed.length) { console.log("=== FAILED (browser) ==="); for (const f of failed) console.log(" " + f); } + const ok = failed.length === 0 && passed.length === CASES.length; + console.log(ok ? "PERL_LOCALE_BROWSER_SMOKE_PASS" : "PERL_LOCALE_BROWSER_SMOKE_FAIL"); + process.exit(ok ? 0 : 1); +} + +main().catch((err) => { console.error("Fatal error:", err); process.exit(1); }); diff --git a/packages/registry/perl/demo/locale-smoke.ts b/packages/registry/perl/demo/locale-smoke.ts new file mode 100644 index 000000000..6ec12fec1 --- /dev/null +++ b/packages/registry/perl/demo/locale-smoke.ts @@ -0,0 +1,178 @@ +/** + * locale-smoke.ts — kd-dvph Perl default/locale startup smoke on Kandelo. + * + * Regression guard for the default-locale startup panic: perl 5.40 built for + * the wasm32 musl target used to abort at interpreter startup when no locale + * env was set, because perl-cross defaulted the target to glibc's + * "cat=value;cat=value" LC_ALL notation while Kandelo's libc (musl) returns a + * POSITIONAL ";"-separated composite ("C.UTF-8;C;C;C;C;C"). perl parsed the + * first field "C.UTF-8" as name=value, found no '=', and panicked (exit 29). + * + * Each case spawns the built perl.wasm under the Node kernel host with a + * controlled guest environment and asserts a clean run (exit 0 + expected + * stdout). The empty-env case is the primary regression; the disparate case + * exercises the exact multi-component positional path that used to panic. + * + * If a Perl runtime tree (PERL5LIB with POSIX.pm/XS, e.g. kd-k7zy's + * perl-runtime) is provided as argv[2], an extra case round-trips + * POSIX::setlocale(LC_ALL) to verify per-category parsing; otherwise that case + * is skipped (baseline `make perl` ships no modules). + * + * Usage: + * bash build.sh && bash packages/registry/perl/build-perl.sh + * npx tsx packages/registry/perl/demo/locale-smoke.ts [PERL5LIB_DIR] + */ +import { existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { NodePlatformIO } from "../../../../host/src/platform/node"; + +const scriptDir = dirname(new URL(import.meta.url).pathname); +const repoRoot = resolve(scriptDir, "../../../.."); + +interface Case { + name: string; + env: string[]; + argv: string[]; + expect: string; + optional?: boolean; +} + +// A trivial arithmetic program that loads no modules, so it works against the +// baseline `make perl` build (no staged runtime). The only thing under test is +// that the interpreter reaches its body instead of panicking during the +// startup locale scan. +const ARITH = 'print "R=", 2 + 3, "\\n"'; + +function buildCases(perl5lib?: string): Case[] { + const cases: Case[] = [ + // PRIMARY regression: no locale env at all -> musl returns the disparate + // positional composite "C.UTF-8;C;C;C;C;C"; used to panic (exit 29). + { name: "unset-locale (no LC_ALL/LANG)", env: [], argv: ["perl", "-e", ARITH], expect: "R=5" }, + { name: "LC_ALL=C", env: ["LC_ALL=C"], argv: ["perl", "-e", ARITH], expect: "R=5" }, + { name: "LC_ALL=C.UTF-8", env: ["LC_ALL=C.UTF-8"], argv: ["perl", "-e", ARITH], expect: "R=5" }, + { name: "LANG=C.UTF-8", env: ["LANG=C.UTF-8"], argv: ["perl", "-e", ARITH], expect: "R=5" }, + // Browser demos default the guest env to LANG=en_US.UTF-8 (live-setup.ts, + // browser-kernel-host.ts). musl maps that to a disparate composite too, so + // this covers the exact env the browser host passes. + { name: "LANG=en_US.UTF-8 (browser default)", env: ["LANG=en_US.UTF-8"], argv: ["perl", "-e", ARITH], expect: "R=5" }, + // Explicitly disparate categories -> forces musl's multi-field positional + // composite; this is the exact code path that panicked, now positionally + // parsed. If the positional category map were wrong the interpreter would + // mis-route or NULL-deref a category during startup. + { + name: "disparate (LC_CTYPE=C.UTF-8, others C)", + env: ["LC_CTYPE=C.UTF-8", "LC_NUMERIC=C", "LC_TIME=C", "LC_COLLATE=C", "LC_MONETARY=C", "LC_MESSAGES=C"], + argv: ["perl", "-e", ARITH], + expect: "R=5", + }, + // Category-mapping correctness (no modules needed): perl's built-in + // ${^UTF8LOCALE} is true iff the *LC_CTYPE* startup locale is UTF-8. In a + // disparate composite it must reflect field 0 (CTYPE) only, so these prove + // the positional map routes CTYPE to the right slot rather than merely not + // panicking. If the order were wrong, CTYPE would pick up a different + // field's value and these would flip. + { + name: "category map: CTYPE=C.UTF-8 disparate -> UTF8LOCALE=1", + env: ["LC_CTYPE=C.UTF-8", "LC_NUMERIC=C", "LC_TIME=C", "LC_COLLATE=C", "LC_MONETARY=C", "LC_MESSAGES=C"], + argv: ["perl", "-e", 'print "U=", (${^UTF8LOCALE} ? 1 : 0), "\\n"'], + expect: "U=1", + }, + { + name: "category map: LC_ALL=C -> UTF8LOCALE=0", + env: ["LC_ALL=C"], + argv: ["perl", "-e", 'print "U=", (${^UTF8LOCALE} ? 1 : 0), "\\n"'], + expect: "U=0", + }, + ]; + + if (perl5lib) { + // With a runtime tree, verify per-category parsing by round-tripping the + // composite through POSIX::setlocale under the disparate env above. This + // fails loudly if the positional order/count is wrong. + const PROG = [ + "use POSIX qw(setlocale LC_ALL LC_CTYPE LC_NUMERIC);", + 'my $all = setlocale(LC_ALL);', + 'my $ctype = setlocale(LC_CTYPE);', + 'my $num = setlocale(LC_NUMERIC);', + 'print "LC_ALL=$all\\nLC_CTYPE=$ctype\\nLC_NUMERIC=$num\\n";', + 'print "POSIX_OK\\n" if $ctype =~ /UTF-?8/i && $num eq "C";', + ].join(" "); + cases.push({ + name: "POSIX::setlocale round-trip (disparate)", + env: [ + `PERL5LIB=${perl5lib}`, + "LC_CTYPE=C.UTF-8", + "LC_NUMERIC=C", + "LC_TIME=C", + "LC_COLLATE=C", + "LC_MONETARY=C", + "LC_MESSAGES=C", + ], + argv: ["perl", "-e", PROG], + expect: "POSIX_OK", + optional: true, + }); + } + return cases; +} + +async function main() { + const perlWasm = resolve(repoRoot, "packages/registry/perl/bin/perl.wasm"); + const perl5lib = process.argv[2]; + if (!existsSync(perlWasm)) { + console.error("perl.wasm not found. Run: bash packages/registry/perl/build-perl.sh"); + process.exit(1); + } + + const passed: string[] = []; + const failed: string[] = []; + const skipped: string[] = []; + + for (const c of buildCases(perl5lib)) { + let result; + try { + result = await runCentralizedProgram({ + programPath: perlWasm, + argv: c.argv, + env: c.env, + io: new NodePlatformIO(), + timeout: 60_000, + }); + } catch (err) { + failed.push(`${c.name}: threw ${String(err)}`); + continue; + } + const ok = result.exitCode === 0 && result.stdout.includes(c.expect); + const detail = `exit=${result.exitCode} stdout=${JSON.stringify(result.stdout.trim())}`; + if (ok) { + passed.push(`${c.name}: ${detail}`); + } else if (c.optional && result.exitCode !== 0) { + // Optional cases (need runtime modules) are skipped, not failed, when the + // interpreter can't load the module — the required cases still gate. + skipped.push(`${c.name}: ${detail} stderr=${JSON.stringify(result.stderr.trim())} (runtime module unavailable)`); + } else { + failed.push(`${c.name}: ${detail} stderr=${JSON.stringify(result.stderr.trim())}`); + } + } + + console.log("=== PASSED ==="); + for (const p of passed) console.log(" " + p); + if (skipped.length) { + console.log("=== SKIPPED ==="); + for (const s of skipped) console.log(" " + s); + } + if (failed.length) { + console.log("=== FAILED ==="); + for (const f of failed) console.log(" " + f); + } + + const allRequiredPass = failed.length === 0; + console.log(allRequiredPass ? "PERL_LOCALE_SMOKE_PASS" : "PERL_LOCALE_SMOKE_FAIL"); + process.exit(allRequiredPass ? 0 : 1); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/registry/perl/demo/runtime-smoke.ts b/packages/registry/perl/demo/runtime-smoke.ts new file mode 100644 index 000000000..9a151302d --- /dev/null +++ b/packages/registry/perl/demo/runtime-smoke.ts @@ -0,0 +1,84 @@ +/** + * runtime-smoke.ts — kd-k7zy Perl core-module runtime smoke on Kandelo. + * + * Runs the built perl.wasm under the Node kernel host (host-fs passthrough so + * PERL5LIB resolves) and checks that the generated core-module runtime library + * loads and that XS core modules bootstrap through XSLoader::load. + * + * Guards the reported gap (kd-k7zy): main's build-perl.sh ran `make perl`, + * which stops before perl-cross's `nonxs_ext extensions` targets that generate + * XSLoader.pm and stage the core-module tree, so File::Spec (-> Cwd -> XSLoader) + * failed. build-perl.sh now runs `make -k` and ships that tree as + * perl-runtime.zip, and builds extensions statically (-Uusedl) so their XS + * loads without dlopen (Kandelo wasm has none). + * + * Usage: + * bash build.sh && bash packages/registry/perl/build-perl.sh + * npx tsx packages/registry/perl/demo/runtime-smoke.ts [PERL5LIB_DIR] + * + * PERL5LIB_DIR defaults to the built privlib staged by build-perl.sh; pass an + * arg (e.g. an unzipped perl-runtime.zip) to smoke the shippable layout. + */ +import { existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { NodePlatformIO } from "../../../../host/src/platform/node"; + +const scriptDir = dirname(new URL(import.meta.url).pathname); +const repoRoot = resolve(scriptDir, "../../../.."); + +// Each check prints "=ok" on success. The XS modules (POSIX/Cwd/...) each +// require XSLoader to bootstrap their statically-linked half, so their success +// also proves XSLoader::load works without dlopen. +const PROG = [ + "use strict; use warnings;", + "my @res;", + "sub ck { my ($n,$c)=@_; my $r=eval { $c->() }; push @res, $n.'='.((defined $r && !$@)?'ok':'FAIL('.(($@=~/^(.*?)(?: at |\\n)/)?$1:'err').')'); }", + // Generated files: Config.pm + XSLoader.pm (the reported missing file). + "ck('Config', sub { require Config; $Config::Config{version} eq '5.40.3' or die 'ver'; 1 });", + "ck('XSLoader', sub { require XSLoader; $XSLoader::VERSION or die; 1 });", + // File::Spec: the reported failing module. catfile is path logic; rel2abs + // pulls in Cwd (an XS module) so it also exercises the XS boot path. + "ck('FileSpec_catfile', sub { require File::Spec; File::Spec->catfile('a','b','c.txt') eq 'a/b/c.txt' or die });", + "ck('FileSpec_rel2abs', sub { File::Spec->rel2abs('x','/root') eq '/root/x' or die });", + // XS core modules that bootstrap through XSLoader::load. + "ck('Cwd_xs', sub { require Cwd; Cwd::getcwd(); 1 });", + "ck('POSIX_xs', sub { require POSIX; POSIX::floor(3.7)==3 or die });", + "ck('Fcntl_xs', sub { require Fcntl; defined Fcntl::O_RDONLY() or die });", + "ck('ListUtil_xs',sub { require List::Util; List::Util::sum(1,2,3,4)==10 or die });", + "ck('DataDumper_xs',sub { require Data::Dumper; Data::Dumper::Dumper([1])=~/1/ or die });", + "print 'PERLVER=',$],\"\\n\";", + "print 'RESULTS=',join(',',@res),\"\\n\";", + "print((grep { !/=ok$/ } @res) ? \"PERL_RUNTIME_SMOKE_FAIL\\n\" : \"PERL_RUNTIME_SMOKE_PASS\\n\");", +].join("\n"); + +async function main() { + const perlWasm = resolve(repoRoot, "packages/registry/perl/bin/perl.wasm"); + const perl5lib = process.argv[2] || + resolve(repoRoot, "packages/registry/perl/perl-src/lib"); + if (!existsSync(perlWasm)) { + console.error("perl.wasm not found. Run: bash packages/registry/perl/build-perl.sh"); + process.exit(1); + } + + const result = await runCentralizedProgram({ + programPath: perlWasm, + argv: ["perl", "-e", PROG], + // LC_ALL=C: perl 5.40 panics at startup parsing the composite default + // locale Kandelo's musl setlocale returns ('C.UTF-8;C;C;C;C;C') -- a + // separate platform boundary (kd-dvph), not this package's gap. + env: [`PERL5LIB=${perl5lib}`, `LC_ALL=C`, `HOME=/tmp`, `TMPDIR=/tmp`], + io: new NodePlatformIO(), + timeout: 300_000, + }); + + process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + const ok = result.exitCode === 0 && result.stdout.includes("PERL_RUNTIME_SMOKE_PASS"); + process.exit(ok ? 0 : (result.exitCode || 1)); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/registry/perl/package.toml b/packages/registry/perl/package.toml index 074c93423..00dddd399 100644 --- a/packages/registry/perl/package.toml +++ b/packages/registry/perl/package.toml @@ -18,3 +18,12 @@ script_path = "packages/registry/perl/build-perl.sh" [[outputs]] name = "perl" wasm = "perl.wasm" + +# Generated core-module runtime library (lib/perl5/5.40.3/*): XSLoader.pm, +# Config*.pm, File::Spec and the rest of the pure-perl core tree that +# perl-cross generates during `make all` (not `make perl`). The Homebrew +# formula installs this and points PERL5LIB at it; without it the bare +# perl.wasm cannot load File::Spec (-> Cwd -> XSLoader). +[[outputs]] +name = "perl-runtime" +wasm = "perl-runtime.zip"