Skip to content

feat(lg): extend -strip to -w bundles - #800

Merged
nnunley merged 1 commit into
mainfrom
feat/strip-wasm-bundles
Sep 7, 2026
Merged

nnunley merged 1 commit into
mainfrom
feat/strip-wasm-bundles

Conversation

@mparrett

@mparrett mparrett commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

What changes

-strip split debug info out of -c and -b artifacts in #624, but rejected -w. A wasm bundle therefore had no way to ship without its source maps and local-variable tables. This makes the flag compose with -w, closing half of #631.

Where the companion goes

Beside the bundle directory, not inside it. Every file in the output directory is served; a debug sidecar is build output, not something to hand to each visitor. -debug-output overrides the path.

Three details that are easy to get wrong:

  • The output directory is canonicalized before the sibling is derived, so dist/, dist/., and . all name the directory itself. Without that, a trailing separator gives a companion named .debug inside the bundle, and dist/. gives dist/..debug.
  • An override is refused when it names the bundle directory or anything under it. A same-path check alone lets -debug-output dist/index.html through, and the build then replaces a generated asset with binary debug data while still reporting success. The same holds for coi-serviceworker.js and, in external mode, main.wasm, so the check is "inside the served directory" rather than a list of filenames.
  • The companion is written last, after the bundle succeeds. These artifacts are digest-bound, so a sidecar left behind by a failed build would claim to describe something that was never produced, which is worse than having none.

Sizes, not oversold

On a real program bundle, xsofy's main.lg compiled to .lgb:

raw gzip -9
plain 709,129 232,854
stripped 583,963 164,882
saving 125,166 (17.7%) 67,972 (29.2%)

The compressed saving is the larger percentage because source maps and local-variable tables are structured numeric data that compresses worse than bytecode and strings, so removing them takes a disproportionate share of the shipped bytes.

Against a whole wasm bundle, though, the program is a small term. A three-line app shrank by 48 bytes on an 8.1 MB bundle, and xsofy's saving is roughly 1.5% of its bundle. This is worth having and nearly free, but the footprint argument in #631 rests on the embedded core and on .lgb artifacts shipped directly, not on the wasm bundle. The embedded-core half is a separate change, the way #501 and #502 were split.

Tests

The strip step lives in pkg/rt/wasm so its tests run without a full wasm build, which would have put minutes and a module fetch into the suite. They cover: the companion landing outside the served directory; every spelling of the directory deriving the same sibling; -w . resolving to the current directory's sibling under its real name; overrides inside the bundle, including the generated asset names, being rejected while siblings and unrelated paths are allowed; a filesystem root being refused; and a stripped bundle plus its companion still decoding together.

Each rejection was checked against the reintroduced bug: with canonicalization disabled the -w . case fails, and with only the same-path check the inside-bundle case fails.

gofmt, go vet, the pkg/rt/wasm, pkg/cli, and pkg/bytecode suites, make check-generated, and cross-builds for linux/amd64, js/wasm, plan9/amd64, and wasip1/wasm are clean.

@mparrett
mparrett requested review from nnunley and nooga September 6, 2026 22:11
@mparrett

mparrett commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review

I found two path-handling issues that should be fixed before merging:

  1. [P2] Normalize outDir before deriving the companion path.

    lg -strip -w dist/. app.lg derives dist/..debug, and -w . derives ..debug. Both put the companion inside the served bundle directory, exposing the debug data this change intends to keep private. outDir should be canonicalized, with . and .. handled explicitly, before the sibling path is derived.

    https://github.com/nooga/let-go/pull/800/files#diff-a385b4de6fe3b94273d51d0bd440ee2f310e31aad7ba1ba7495a30b534d7209cR33

  2. [P2] Prevent -debug-output from overwriting generated bundle assets.

    The validation only compares the companion path with the directory itself. For example, -debug-output dist/index.html -w dist passes validation, then replaces index.html with binary debug data while the command still succeeds. The same applies to coi-serviceworker.js and, in external mode, main.wasm. Canonicalize and compare the companion path against every generated output before writing the bundle.

    https://github.com/nooga/let-go/pull/800/files#diff-a385b4de6fe3b94273d51d0bd440ee2f310e31aad7ba1ba7495a30b534d7209cR41

Focused validation passed for the root package, pkg/rt/wasm, and pkg/bytecode; the PR's CI checks are green.

`-strip` split debug info out of `-c` and `-b` artifacts (#624) but rejected
`-w`, so a wasm bundle had no way to ship without its source maps and
local-variable tables. It now composes with `-w`.

The companion is written beside the bundle directory, not inside it. Every
file in the output directory is served; a debug sidecar is build output, not
something to hand to each visitor. The output directory is canonicalized
first, so `dist/`, `dist/.` and `.` all derive the same sibling instead of a
dotfile inside the bundle. `-debug-output` overrides the path and is refused
when it names the bundle directory or anything under it: an override such as
`dist/index.html` would pass a same-path check and then replace a generated
asset with binary debug data.

The companion is written last, after the bundle succeeds, so a failed build
leaves no sidecar claiming to describe an artifact that was never produced;
these are digest-bound, and a stale one is worse than none.

Sizes, so the scale is not oversold. On a real program bundle (xsofy's
main.lg compiled to .lgb) stripping takes 709,129 B to 583,963 B, and the
gzipped artifact from 232,854 B to 164,882 B: 17.7% raw but 29.2%
compressed, because source maps and local-variable tables are structured
numeric data that compresses worse than bytecode and strings. Against a whole
wasm bundle the program is a small term: a three-line app shrank by 48 bytes
on 8.1 MB, and xsofy's saving is roughly 1.5% of its bundle. Worth having and
nearly free; the larger footprint argument belongs to the embedded core.

The strip step lives in pkg/rt/wasm so its tests run without a wasm build.
They cover the companion landing outside the served directory, every
spelling of the directory deriving the same sibling, `-w .` resolving to the
cwd's sibling, overrides inside the bundle (including the generated asset
names) being rejected while siblings are allowed, and the stripped bundle
plus companion still decoding together. Each was checked against a
reintroduced bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mparrett
mparrett force-pushed the feat/strip-wasm-bundles branch from 18d7db3 to 994e03b Compare September 7, 2026 13:07
@mparrett

mparrett commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Both addressed at 994e03b8, which also rebases the branch onto the CLI's new home in pkg/cli after #773.

  1. The output directory is canonicalized before the sibling is derived. . and .. spellings resolve to an absolute path so the companion takes the directory's real name; a filesystem root is refused. dist/. now derives dist.debug, and -w . from inside dist does the same.
  2. An override is refused when it is the bundle directory or anything under it, compared on canonical absolute paths. That covers index.html, coi-serviceworker.js, and external-mode main.wasm without enumerating them. -debug-output dist/index.html -w dist now fails before the build starts and leaves the existing index.html untouched.

Verified end to end with the rebuilt lg on a three-line app: -w dist/., -debug-output dist/index.html, -w-wasm external -debug-output dist/main.wasm, and -strip without a target all behave as described.

Observed and left alone: an override whose parent directory does not exist (-debug-output symbols/app.debug) fails at write time, after the wasm build has run. -c and -b fail the same way today, so it is the existing convention rather than something introduced here.

@nooga

nooga commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Review summary

Ran a multi-angle review (correctness, reuse/simplification/efficiency, altitude, conventions) plus hands-on repro of the wasm build path.

Found two real path-handling bugs in the original SplitProgramDebug:

  1. -debug-output pointing at a file inside the served output directory (e.g. -w dist -debug-output dist/index.html) wasn't rejected — the companion write landed last and silently clobbered the generated asset with binary debug data, exiting 0.
  2. The collision guard compared raw, unnormalized strings, so -debug-output ./dist vs -w dist escaped it and only failed late with an opaque is a directory error.

Both were independently caught in your self-review and fixed at 994e03b (canonicalBundleDir + filepath.Abs/Rel-based rejectInsideBundle). I rebuilt from that commit and re-ran my original repros — both now fail fast, before the wasm build starts, with clear errors. The new tests (TestSplitProgramDebugRejectsOverrideInsideBundle, TestSplitProgramDebugCanonicalizesOutDir, TestSplitProgramDebugDotResolvesToCwdSibling, TestSplitProgramDebugRejectsRoot) cover exactly the cases that were previously untested. go test ./pkg/rt/wasm/... ./pkg/bytecode/... is green.

Left open, non-blocking:

  • No test for the widened -strip requires -c, -b or -w flag-guard error text (pkg/cli/cli.go) — same pattern as the existing TestCompressFlagRequiresCompileOrBundle for -z would cover it cheaply.
  • The wasm-embedded runtime never reads a .debug companion back at runtime (the generated main.go only calls the plain decoder) — unlike -c/-b, where a sibling .debug is transparently reattached. Likely an accepted limitation given the browser sandbox has no filesystem access to a sibling file, but worth a doc note so users don't expect -c/-b's auto-reattach behavior here.

The missing-parent-directory case you flagged yourself as "observed and left alone" checks out — same pre-existing convention as -c/-b, not a regression.

LGTM — approving.

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the path-handling fix at 994e03b fixes both bugs found in review (repro'd before/after). See summary comment for details.

@nnunley
nnunley merged commit 3aa9f4e into main Sep 7, 2026
21 checks passed
@nnunley
nnunley deleted the feat/strip-wasm-bundles branch September 7, 2026 17:06
mparrett added a commit to nnunley/let-go-wiki that referenced this pull request Sep 20, 2026
Second batch off [#28](#28). Every number was re-counted against `origin/main` at `36b13f79`, which changed two of them relative to the sweep that scheduled the work.

## `concepts/stack-vm.md`

"The ~37 opcodes" is **47**, and not the 48 an enum count suggests: `OP_COUNT` is a sentinel (`// keep last; must equal len(opcodeNames)`), `len(opcodeNames)` is 47, `init()` panics if they disagree, and `lg -v` on 1.13.0 prints `opcodes: 47 (signature 7cf8f862cf5ba1e5)`. Three sources agreeing seemed worth the trouble, since this is the number the `.lgb` capability check compares.

The roles list gained the unchecked trio ([#811](nooga/let-go#811)) and a bitwise bullet it had always omitted, which is eight opcodes the page never grouped.

The `Frame` listing was seven fields stale, missing `argbuf`, `prepArgs`, `argc`, `constsc`, `debug`, `prevOp`, `profileOn` and, load-bearingly, `parent`. That last one is what [#645](nooga/let-go#645) hangs on, so the page's flat claim that "cross-frame propagation *does* unwind Go frames" is now qualified: a contiguous span of direct bytecode calls runs as child frames inside one dispatch loop, and `releaseFailedFrames` walks the `parent` chain offering the error to each suspended handler. It is still true once the call leaves bytecode through `ec.Invoke`, so the caveat is narrowed rather than dropped.

The overflow paragraph gained the `*unchecked-math*` caveat ([#839](nooga/let-go#839)).

## `concepts/op-catalog.md` — the page was right

"39 at `0911118`" was **correct**, and is kept as the prior value. Flagging it because the miscount is easy to repeat: a grep for op rows in `pkg/ir/ir_ops.lg` misses the `Invalid` row, which is written on a `'[[` line rather than a `["` line. Counting it gives 39 then and **42** now, the three new ones being #811's.

## `concepts/debug-info.md` and `concepts/lgb-bytecode-format.md`

Both said "the `-w`/WASI paths are not stripped". [#800](nooga/let-go#800) made `-strip` compose with `-w`; `cli.go` now only refuses `-strip` when none of `-c`, `-b` or `-w` is given.

`lgb-bytecode-format` also carried an illustrative "runtime has 44" pinned to `0911118`, now the real 47 and signature. Its capability section names #811 as the second deliberate opcode-set break after the one that added `OP_DIV`, which is the thing most likely to reach someone as an unexplained rejection after upgrading.

## `sources/let-go-source-code.md`

"`/pkg` holds thirteen main packages" is **18**. The five newer ones are named rather than described, with [#773](nooga/let-go#773) called out: moving the CLI into `pkg/cli` is the one that changes an import for anyone building their own `lg`.

`check_wiki.py` passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants