Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/registry/perl/build-perl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,28 @@ if [ -f "$SRC_DIR/perl" ]; then
echo "==> Re-collected perl.wasm after make -k ($(wc -c < "$BIN_DIR/perl.wasm" | tr -d ' ') bytes)."
fi

# --- Size optimization (post-link, PRE fork-instrumentation) ---
# The link above uses a load-bearing --no-gc-sections (so wasm-ld's
# --allow-undefined does not strip live Perl code), which leaves dead code from
# the curated static extensions in the binary. wasm-opt runs its own DCE +
# simplification to reclaim it. It MUST run BEFORE fork instrumentation:
# install_local_binary applies wasm-fork-instrument (auto policy -- perl imports
# kernel_fork), and that pass hardcodes mutable-global offsets, so any later
# pass that reordered globals would corrupt the fork buffer. wasm-opt here is
# therefore the last transform before install-time instrumentation.
# -O2 (not -Oz) matches every other fork-instrumented package (bash/git/php/vim)
# and preserves interpreter throughput; -Oz measured only ~0.45% smaller.
WASM_OPT="$(command -v wasm-opt 2>/dev/null || true)"
if [ -z "$WASM_OPT" ]; then
echo "ERROR: wasm-opt not found. Install binaryen (declared in the dev-shell flake)." >&2
exit 1
fi
OPT_BEFORE=$(wc -c < "$BIN_DIR/perl.wasm" | tr -d ' ')
echo "==> Optimizing perl.wasm with wasm-opt -O2 (pre-instrumentation)..."
"$WASM_OPT" -O2 "$BIN_DIR/perl.wasm" -o "$BIN_DIR/perl.wasm"
OPT_AFTER=$(wc -c < "$BIN_DIR/perl.wasm" | tr -d ' ')
echo "==> wasm-opt -O2: $OPT_BEFORE -> $OPT_AFTER bytes (fork instrumentation is added by install_local_binary)."

echo "==> Packaging Perl runtime library (lib/perl5/$PERL_VERSION)..."
RUNTIME_STAGE="$WORK_DIR/perl-runtime-stage"
rm -rf "$RUNTIME_STAGE"
Expand Down
2 changes: 1 addition & 1 deletion packages/registry/perl/build.toml
Original file line number Diff line number Diff line change
@@ -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"
81 changes: 81 additions & 0 deletions test-runs/kd-lfas/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# kd-lfas — perl.wasm size: wasm-opt post-link pass

**Date:** 2026-07-02 · **Worktree:** `worktrees/kandelo/kd-lfas-perl.wasm-size-...`
· **Base:** kd-k7zy tip `0150a567` (build-perl.sh rev 2, PR #821, unmerged) ·
**Env:** `scripts/dev-shell.sh` (binaryen wasm-opt v126, LLVM 21.1.7, Node 24)

## Decision
**LAND** `wasm-opt -O2` as a **post-link, pre-fork-instrumentation** pass in
`build-perl.sh`; bump `build.toml` revision 2 → 3. Safe **−469,546 B (−6.44%)**
reduction on the shipped binary, all functional gates green.

## Key structural finding (reframes the bead premise)
The shipped `bin/perl.wasm` (7,291,919 B) is **already fork-instrumented** —
`install_local_binary` applies `wasm-fork-instrument` (auto policy; perl imports
`kernel_fork`+`kernel_execve`). Proof: `instrument(perl-src/perl)` reproduces the
shipped binary **byte-identically** (sha `f9d77c1a…`).

- Raw `make -k` output (pre-instrument): **4,232,959 B**
- Fork instrumentation overhead: **+3,058,960 B (+72%; ~42% of the shipped binary)**

So size is dominated by (a) static perl + 30 curated XS extensions and (b) fork
instrumentation — **not** primarily `--no-gc-sections` dead code. wasm-opt DCE on
the raw code reclaims ~0.45 MB; it cannot touch the instrumentation overhead.
(The bead "Context" note's "4232959 vs 7291919" is exactly raw-vs-instrumented,
not a build discrepancy.)

## Measurement (correct pipeline: wasm-opt raw → fork-instrument)
| Pipeline | raw opt | final (instrumented) | Δ vs 7,291,919 |
|---|---|---|---|
| shipped (no wasm-opt) | 4,232,959 | 7,291,919 | — |
| **-O2 → instrument (LANDED)** | 3,786,690 | **6,822,373** | **−469,546 (−6.44%)** |
| -Oz → instrument (alt) | 3,732,318 | 6,789,290 | −502,629 (−6.89%) |

`-Oz` is only **33,083 B (0.45%)** smaller than `-O2`. Chose **-O2** to match every
other fork-instrumented package (bash/git/php/vim) and preserve interpreter
throughput.

## Why the pass MUST precede instrumentation
Sibling recipes document it: *"wasm-fork-instrument must run LAST because it
hardcodes mutable-global offsets at instrument time — any later pass that
reordered globals would corrupt the fork buffer."* Confirmed empirically:
- Optimizing the **already-instrumented** shipped binary with `wasm-opt -all` produced
an `exnref` (Exn heap-type) local that **breaks** `wasm-fork-instrument`
(`ref-typed local … Abstract(Exn) … not yet supported`). Root cause: `-all`
enabled features the input didn't declare. The landed pass uses plain `-O2`
(honors the input's `target_features`: bulk-memory, exception-handling,
reference-types, threads, …), so no unsupported reftypes are introduced.

## Verification (all on the ACTUAL recipe output, 6,822,373 B, 5 WPK exports)
Reproduced the recipe tail with the **real** `install_local_binary` auto-instrument
(logged "applying wasm-fork-instrument to perl.wasm" → 6,822,373 B in
`local-binaries/programs/wasm32/perl/`).

- **runtime-smoke.ts (kd-k7zy, 9 checks): 9/9 PERL_RUNTIME_SMOKE_PASS**
(Config, XSLoader, File::Spec catfile/rel2abs, Cwd, POSIX, Fcntl, List::Util, Data::Dumper)
- **ext-smoke.ts (21 checks): 21/21 EXT_SMOKE_PASS** — arithmetic, floats, strings,
sprintf, regex (named/subst/tr/unicode-/u), sort (numeric+Schwartzian), hashes,
pack/unpack, POSIX math, List::Util, refs/closures. Baseline == optimized on all.
- **fork-smoke.ts (5 checks): FORK_SMOKE_PASS** — fork()+pipe+waitpid+exit-status,
system(LIST) execve, open('-|',LIST). Identical PASS on shipped baseline and
optimized (both single-instrumented). This is the gate the runtime/ext smokes
cannot cover (they never fork).

## Notes / limitations
- Not byte-reproducible: two runs of the same pipeline give the same **size**
(6,822,373) but different sha (`ee5a4479…` vs `214f99ac…`). This is a
pre-existing property of `wasm-fork-instrument` (affects all fork-instrumented
packages), not introduced here. Revision tracks size/behavior, not byte-exactness.
- Perl **source compile not re-run**: the edit only adds a post-link pass; the raw
binary is deterministic and was validated byte-identical to kd-k7zy's shipped
build via `instrument(raw)==shipped`. The added glue (`wasm-opt -O2` +
`install_local_binary`) was exercised end-to-end.
- Perl fork/system had **no durable regression test**; `fork-smoke.ts` is
verification scaffolding here. Candidate follow-up: promote to a demo/CI test
(coordinate with kd-gk6o perl-smoke CI wiring).

## Artifacts
- `test-runs/kd-lfas/ext-smoke.ts`, `fork-smoke.ts` — verification harnesses
- `test-runs/kd-lfas/tests-passed.txt`, `tests-failed.txt`, `tests-skipped.txt`
- Recipe change: `packages/registry/perl/build-perl.sh` (+wasm-opt -O2 block),
`packages/registry/perl/build.toml` (revision 2→3)
76 changes: 76 additions & 0 deletions test-runs/kd-lfas/ext-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* ext-smoke.ts — kd-lfas extended functional battery for perl.wasm.
*
* Broader than demo/runtime-smoke.ts (which checks module load / XS bootstrap):
* this exercises arithmetic, floats, string ops, regex+unicode, sort, hashes,
* pack/unpack (bit-level, codegen-sensitive), sprintf formats, math funcs, and
* refs/closures — to rule out subtle wasm-opt miscompilation of the optimized
* binary, not just "does it still boot".
*
* Usage: tsx test-runs/kd-lfas/ext-smoke.ts <perl.wasm> [PERL5LIB_DIR]
*/
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, "../..");

const PROG = [
"use strict; use warnings;",
"use List::Util qw(sum max min reduce first);",
"use POSIX qw(floor ceil pow);",
"my @res;",
"sub ck { my ($n,$c)=@_; my $r=eval { $c->() }; push @res, $n.'='.((defined $r && $r && !$@)?'ok':'FAIL('.(($@=~/^(.*?)(?: at |\\n)/)?$1:('got='.(defined $r?$r:'undef'))).')'); }",
// integer + float arithmetic
"ck('int_arith', sub { (7*6==42) && (2**10==1024) && (17%5==2) && (int(-7/2)==-3) });",
"ck('float_pi', sub { sprintf('%.5f', atan2(1,1)*4) eq '3.14159' });",
"ck('float_sqrt', sub { abs(sqrt(2)-1.4142135623731) < 1e-9 });",
"ck('bigmul', sub { (1_000_000 * 1_000_000) == 1e12 });",
// string ops
"ck('str_basic', sub { my $s='Kandelo'; (uc($s) eq 'KANDELO') && (reverse('abc') eq 'cba') && (substr($s,0,3) eq 'Kan') && (index($s,'del')==3) });",
"ck('str_join_split', sub { join('-',split(/,/, 'a,b,c')) eq 'a-b-c' });",
"ck('str_repeat', sub { ('ab' x 3) eq 'ababab' && length('x' x 1000)==1000 });",
// sprintf format battery (codegen + libc)
"ck('sprintf_fmt', sub { sprintf('%05d|%x|%o|%.3e|%+d', 42, 255, 8, 12345.678, 7) eq '00042|ff|10|1.235e+04|+7' });",
// regex incl. named captures, substitution, tr, unicode word char
"ck('re_named', sub { 'ver=5.40' =~ /ver=(?<maj>\\d+)\\.(?<min>\\d+)/; ($+{maj}==5 && $+{min}==40) });",
"ck('re_subst', sub { (my $t='aaa') =~ s/a/b/g; $t eq 'bbb' });",
"ck('re_tr', sub { (my $t='hello') =~ tr/a-z/A-Z/; $t eq 'HELLO' });",
// /u forces Unicode \\w semantics regardless of the string's utf8 flag, so\n // the letter U+00E9 (e-acute) matches; without /u a Latin-1 (non-utf8) string\n // uses ASCII \\w rules -- correct Perl semantics, not a codegen issue.\n "ck('re_unicode', sub { my $c=\"caf\\x{e9}\"; ($c =~ /^\\w+$/u) ? 1 : 0 });",
// sort: numeric + Schwartzian transform
"ck('sort_num', sub { join(',', sort { $a <=> $b } (10,2,33,4)) eq '2,4,10,33' });",
"ck('sort_schwartz', sub { my @w=qw(ccc a bb); join(',', map {$_->[1]} sort {$a->[0]<=>$b->[0]} map {[length($_),$_]} @w) eq 'a,bb,ccc' });",
// hashes
"ck('hash_ops', sub { my %h=(a=>1,b=>2,c=>3); (exists $h{b}) && (join(',',sort keys %h) eq 'a,b,c') && (sum(values %h)==6) && (delete $h{a}==1) && (!exists $h{a}) });",
// pack/unpack — bit/byte level, sensitive to codegen
"ck('pack_N', sub { unpack('N', pack('N', 0xDEADBEEF)) == 0xDEADBEEF });",
"ck('pack_mixed', sub { my $p=pack('A3 n C', 'abc', 513, 7); join(',', unpack('A3 n C', $p)) eq 'abc,513,7' });",
// POSIX math
"ck('posix_math', sub { (floor(3.7)==3) && (ceil(3.2)==4) && (pow(2,8)==256) });",
// List::Util
"ck('listutil', sub { (max(3,9,1)==9) && (min(3,9,1)==1) && (reduce {$a*$b} 1..5)==120 && (first {$_>3} 1..10)==4 });",
// refs / closures / deref
"ck('refs', sub { my $ar=[1,2,3]; my $hr={x=>10}; my $cr=sub {$_[0]+1}; ($ar->[1]==2) && ($hr->{x}==10) && ($cr->(41)==42) });",
"ck('closure', sub { my $n=0; my $inc=sub {$n++}; $inc->() for 1..5; $n==5 });",
"print 'EXTVER=',$],\"\\n\";",
"print 'EXTRESULTS=',join(',',@res),\"\\n\";",
"print((grep { !/=ok$/ } @res) ? \"EXT_SMOKE_FAIL\\n\" : \"EXT_SMOKE_PASS\\n\");",
].join("\n");

async function main() {
const perlWasm = resolve(process.argv[2] || "");
const perl5lib = process.argv[3] || resolve(repoRoot, "packages/registry/perl/perl-src/lib");
const result = await runCentralizedProgram({
programPath: perlWasm,
argv: ["perl", "-e", PROG],
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("EXT_SMOKE_PASS");
process.exit(ok ? 0 : (result.exitCode || 1));
}
main().catch((e) => { console.error("Fatal:", e); process.exit(1); });
54 changes: 54 additions & 0 deletions test-runs/kd-lfas/fork-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* fork-smoke.ts — kd-lfas fork/exec safety check for an INSTRUMENTED perl.wasm.
*
* The runtime/ext smokes run the raw binary and never fork, so they cannot
* catch a wasm-opt transform that breaks wasm-fork-instrument's save/restore.
* Perl imports kernel_fork+kernel_execve and its package.toml fork policy is
* "auto", so the resolver fork-instruments perl at load. This drives the
* post-instrument binary through: pure fork()+pipe+waitpid+exit-status,
* system(LIST) (direct execve), and open('-|',LIST) (fork+exec+pipe capture).
*
* Usage: tsx test-runs/kd-lfas/fork-smoke.ts <INSTRUMENTED perl.wasm> [PERL5LIB]
*/
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, "../..");

const PROG = [
"use strict; use warnings;",
"my @res; sub ck { my ($n,$ok)=@_; push @res, $n.'='.($ok?'ok':'FAIL'); }",
// 1) pure fork(): pipe IPC + waitpid + exit status — exercises the
// instrumented call-stack save/restore directly.
"pipe(my $r, my $w) or die \"pipe: $!\";",
"my $pid = fork(); die \"fork undef: $!\" unless defined $pid;",
"if (!$pid) { close $r; syswrite($w,'hello-from-child'); close $w; exit 3; }",
"close $w; my $msg = do { local $/; <$r> }; close $r; waitpid($pid,0); my $code = $? >> 8;",
"ck('fork_pid', $pid > 0); ck('fork_pipe', defined $msg && $msg eq 'hello-from-child'); ck('fork_exit', $code == 3);",
// 2) system(LIST): fork + direct execve of a child perl (no shell).
"my $rc = system('/bin/perl','-e','exit 5'); ck('system_exit', ($rc >> 8) == 5);",
// 3) open('-|',LIST): fork + execve + pipe capture (no shell).
"my $out=''; if (open(my $fh,'-|','/bin/perl','-e',\"print 'PIPE-OK'\")) { local $/; $out=<$fh>; close $fh; } ck('open_pipe', defined $out && $out =~ /PIPE-OK/);",
"print 'FORKRESULTS=', join(',', @res), \"\\n\";",
"print((grep { !/=ok$/ } @res) ? \"FORK_SMOKE_FAIL\\n\" : \"FORK_SMOKE_PASS\\n\");",
].join("\n");

async function main() {
const perlWasm = resolve(process.argv[2] || "");
const perl5lib = process.argv[3] || resolve(repoRoot, "packages/registry/perl/perl-src/lib");
const result = await runCentralizedProgram({
programPath: perlWasm,
argv: ["perl", "-e", PROG],
env: [`PERL5LIB=${perl5lib}`, `LC_ALL=C`, `HOME=/tmp`, `TMPDIR=/tmp`, `PATH=/bin`],
execPrograms: new Map([["/bin/perl", perlWasm]]),
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("FORK_SMOKE_PASS");
process.exit(ok ? 0 : (result.exitCode || 1));
}
main().catch((e) => { console.error("Fatal:", e); process.exit(1); });
21 changes: 21 additions & 0 deletions test-runs/kd-lfas/tests-failed.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# kd-lfas FAILED checks on the FINAL landed recipe output (6822373 B): NONE (0).
#
# Investigated failures on REJECTED candidates / test bugs (not the shipped output):
#
# 1. wasm-opt -all -Oz (REJECTED candidate): fork-instrumentation FAILED —
# "fork-instrument: function `<anon>` has a ref-typed local of type
# RefType { nullable: false, heap_type: Abstract(Exn) } which is not yet supported".
# Cause: `-all` enabled exception-handling/reference features the input did not
# declare, introducing exnref locals wasm-fork-instrument cannot handle.
# Resolution: land plain `-O2` (honors input target_features) — instruments cleanly.
#
# 2. ext-smoke re_unicode (initial run, TEST-EXPECTATION bug, since fixed):
# "caf\x{e9}" =~ /^\w+$/ returned false on BOTH baseline and optimized —
# correct Perl semantics for a Latin-1 (non-utf8) string without /u.
# Fixed the test to use /^\w+$/u; both binaries then 21/21. Not a binary defect.
#
# 3. Direct-run of a DOUBLE-instrumented binary (invalid harness step, discarded):
# "WebAssembly.compile(): Duplicate export name 'wpk_fork_state'" — occurred
# because the shipped binary is ALREADY instrumented; re-instrumenting duplicated
# exports. Identical on baseline and optimized. Resolved by using single-instrument
# binaries (the real recipe output).
46 changes: 46 additions & 0 deletions test-runs/kd-lfas/tests-passed.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# kd-lfas PASSED checks — run against the ACTUAL recipe output
# packages/registry/perl/bin/perl.wasm = 6822373 bytes (wasm-opt -O2 + fork-instrument), 5 WPK exports
# All also pass identically on the shipped baseline (7291919 B); wasm-opt introduced ZERO behavioral change.

## runtime-smoke.ts (kd-k7zy harness) — 9/9 PERL_RUNTIME_SMOKE_PASS
Config
XSLoader
FileSpec_catfile
FileSpec_rel2abs
Cwd_xs
POSIX_xs
Fcntl_xs
ListUtil_xs
DataDumper_xs

## ext-smoke.ts (kd-lfas) — 21/21 EXT_SMOKE_PASS
int_arith
float_pi
float_sqrt
bigmul
str_basic
str_join_split
str_repeat
sprintf_fmt
re_named
re_subst
re_tr
re_unicode
sort_num
sort_schwartz
hash_ops
pack_N
pack_mixed
posix_math
listutil
refs
closure

## fork-smoke.ts (kd-lfas, INSTRUMENTED binary) — 5/5 FORK_SMOKE_PASS
fork_pid # fork() returns child pid in parent
fork_pipe # pipe() IPC child->parent survives fork save/restore
fork_exit # waitpid() exit status ($? >> 8 == 3)
system_exit # system(LIST) fork+execve child perl, status == 5
open_pipe # open('-|',LIST) fork+execve+pipe capture

TOTAL PASSED: 35/35 checks (9 runtime + 21 ext + 5 fork) on the optimized recipe output.
22 changes: 22 additions & 0 deletions test-runs/kd-lfas/tests-skipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# kd-lfas SKIPPED / not-run, with reasons
#
# 1. Full perl SOURCE recompile via build-perl.sh from scratch — SKIPPED.
# Reason: the edit only ADDS a post-link pass; the raw `make -k` output is
# deterministic and was validated byte-identical to kd-k7zy's shipped build
# (instrument(perl-src/perl) == shipped, sha f9d77c1a). The added glue
# (wasm-opt -O2 + real install_local_binary auto fork-instrument) WAS exercised
# end-to-end and produced the verified 6822373 B output. A fresh source build
# also needs the musl sysroot (not staged in this worktree). Recompile would
# only re-validate the unchanged perl compile, at multi-ten-minute cost.
#
# 2. Browser-tier smoke of the optimized perl.wasm — SKIPPED (skip-with-reason).
# Reason: this is a package BUILD change producing one smaller wasm binary that
# BOTH hosts consume identically (not a host-runtime code change). Functional
# correctness was validated on the Node host (runtime+ext+fork). The identical
# bytes load in the browser host via the same resolver path. Candidate follow-up:
# a browser-tier perl smoke (coordinate with kd-gk6o perl-smoke CI wiring).
#
# 3. Runtime performance (perl throughput) delta of -O2 vs -Oz vs baseline —
# NOT MEASURED. Reason: bead scope is SIZE; -O2 chosen for convention parity +
# interpreter-perf safety over -Oz (0.45% larger only). No perf regression claim
# is made; a perf baseline is a possible follow-up if -O1/-Oz is ever considered.
Loading