diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 42f3f2065..b3d9e5599 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 diff --git a/packages/registry/perl/build-perl.sh b/packages/registry/perl/build-perl.sh index 21ba8c91d..9e3b74d05 100755 --- a/packages/registry/perl/build-perl.sh +++ b/packages/registry/perl/build-perl.sh @@ -225,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" @@ -631,7 +683,7 @@ PRIVLIB_SRC="$SRC_DIR/lib" # 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; do +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 @@ -639,7 +691,7 @@ if [ -n "$missing" ]; then 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)." +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 diff --git a/packages/registry/perl/build.toml b/packages/registry/perl/build.toml index f0f6f36f8..644271513 100644 --- a/packages/registry/perl/build.toml +++ b/packages/registry/perl/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/perl/build-perl.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +revision = 3 [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); +});