Files
cfdd475d60 gcasm: reach remote fns from fallback bodies via trampolines, never linkname pulls; consumer-link gates (#65)
* gcasm: reach remote fns from fallback bodies via trampolines, never linkname pulls; link gates

Root cause of the unlinkable llamawasm2go v0.3.1 bundle ("nosplit stack
over 792 byte limit ... infinite cycle" on linux/arm64 and linux/amd64
GOAMD64=v2), latent since the direct cross-chunk calls (#61):

A chunk package whose pure-Go fallback bodies call a remote FnN
declared that FnN through a bodyless //go:linkname pull, while the
same package's transformed asm CALLed the remote through its spelled
dotted symbol. symabis reports the asm reference, the pull supplies a
declaration, and the compiler emits a DUPOK ABI0 wrapper under the
REMOTE symbol's name. The linker keeps whichever of that wrapper and
the remote's real TEXT it loads first; when the wrapper wins, the
remote's ABIInternal wrapper and the DUPOK ABI0 wrapper call each other
forever and the link fails. Small fixtures never had a fallback body
calling a remote fn, so the shape went unexercised; the llama bundle
has hundreds of such overlaps, and which of them fail is decided by
package load order.

Fix (the direct asm-to-asm CALLs are untouched — they are the hop-count
win and are harmless on their own):

- Remote fns referenced from fallback bodies on a plan-9-spellable
  owner path get a LOCAL bodyless decl implemented by a NOSPLIT $0
  tail-JMP trampoline to the spelled remote symbol, so the compiler
  binds the decl to this package's own asm and never declares the
  remote symbol. Unspellable owner paths keep the linkname pull (no
  asm reference can be spelled there). The trampoline design the
  bundle.go comments described was never implemented; it is now.
- abi0ArgBytes derives the trampolines' argument frame from the wasm
  signature (validated against real bundle TEXT frames).
- Build refuses to emit a package whose //go:linkname-pulled symbols
  intersect its spelled asm references (crossChunkPullConflicts), so
  the hazard is a generator error, never a downstream link failure.

Gates:

- cg_crosscall_fallbackcaller fixture (fallback caller + transformed
  sibling packed into one chunk, calling a recursive remote); shape
  tests for the trampoline / pull split; a consumer link test for
  linux/arm64, linux/amd64 v2 and v1 with the chunk packages imported
  in ascending AND descending order (the pre-fix generator fails the
  descending order exactly like the shipped bundle); gate4 executes
  the fixture's exports.
- make llama-bundle-link (+ CI job): transpile the pinned llama-wasm
  release wasm with llama-wasm's own options and link a consumer that
  reaches every export for the three targets. With this change the
  v0.3.1 llama.wasm links on all of them.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: link the llama bundle consumer in both chunk import orders; serialize the capture builds

The consumer now imports the chunk packages explicitly in ascending
and descending order (see scripts/bundle-link-consumer.sh): the linker
resolves DUPOK symbols first-come, so a wrapper emitted under another
package's symbol breaks the link only when the emitting package loads
first, and a single import order passed on a defective bundle by
layout luck in a downstream CI run while the shipped v0.3.1 failed.

The llama-bundle-link job was terminated (exit 143) four minutes in:
the gcasm capture builds the chunk packages in parallel and exhausts
the runner. GOFLAGS=-p=1 and GOMEMLIMIT bound it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: trace memory/disk around the llama bundle link job

The job is terminated with exit 143 about four minutes after the SSA
phase, during the gcasm capture, with no message from the runner and
regardless of GOFLAGS=-p=1 / GOMEMLIMIT. Sample free/df/top-RSS every
ten seconds and print the tail (and any OOM kernel lines) in an
always-run step so the next termination carries its cause.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: stream the llama bundle job's resource samples into the step log

The always() trace step never ran: after the exit-143 termination the
runner skipped every remaining step, post steps included, i.e. the
runner itself went away. Emit the free/df/top-RSS samples from a
background loop inside the transpile step so the streamed step log
keeps everything sampled before the runner dies, and print the
runner's cpu/RAM/disk at the start.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: name the package the top-RSS compile is building in the resource samples

The samples show one gc compile process at 15 GiB on the 16 GiB
runner before it dies; record its -p import path so the offending
chunk package is identified.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: build the llama bundle gate with the latest Go 1.25 patch, not go.mod's floor

The job was terminated (exit 143) on every run: go1.25.0's compiler
needs 12-15 GiB for the largest chunk package (p0) and takes the 16 GiB
runner down, with every later step skipped. The same package compiles
in 1.8 GiB with go1.26.2 (measured locally, cold cache, -p=1), and the
production bundle is built from golang:1.25 (latest patch) inside the
wasmify image, so pin the gate to the latest 1.25 patch. GOMEMLIMIT is
dropped (it did not help; the live heap is the problem); -p=1 stays.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* gcasm: drop the llama-specific bundle gate from this repo

wasm2go is a general transpiler and carries no configuration tied to
one downstream project: the pinned goccy/llama-wasm release, its
transpile options, the Makefile target and the CI job that reproduced
its bundle do not belong here. The consumer-link check lives in the
repos that own that bundle (goccy/llama-wasm, goccy/llamawasm2go);
this repo keeps the project-neutral gates — the Build-time
pull/spelled-reference conflict error and the fixture link test in
both chunk import orders.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-03 09:27:53 +09:00

47 lines
1.8 KiB
Go

package gcasm
import (
"testing"
"github.com/goccy/wasm2go/internal/wasm"
)
// The expected sizes are the "-N" values the gc toolchain assigned to
// real bundle TEXT directives for these signatures ($128-16, $16-20,
// $304-24, $16-36, $448-56), so the trampolines' frames match what the
// Go declarations promise.
func TestABI0ArgBytes(t *testing.T) {
i32, i64, f32 := wasm.ValI32, wasm.ValI64, wasm.ValF32
cases := []struct {
name string
params []wasm.ValType
results []wasm.ValType
want int
}{
{"(m, i64)", []wasm.ValType{i64}, nil, 16},
{"(m, f32) f32: result section pointer-aligned", []wasm.ValType{f32}, []wasm.ValType{f32}, 20},
{"(m, i64) i64", []wasm.ValType{i64}, []wasm.ValType{i64}, 24},
{"(m, 7 x i32): no result, no tail rounding", []wasm.ValType{i32, i32, i32, i32, i32, i32, i32}, nil, 36},
{"(m, i64, i32, i32, i64, i64, i64) i64", []wasm.ValType{i64, i32, i32, i64, i64, i64}, []wasm.ValType{i64}, 56},
{"(m, i32) i64: i64 result aligns past the i32", []wasm.ValType{i32}, []wasm.ValType{i64}, 24},
{"(m, i32, i64): i64 param aligns to 8", []wasm.ValType{i32, i64}, nil, 24},
}
for _, c := range cases {
if got := abi0ArgBytes(wasm.FuncType{Params: c.params, Results: c.results}); got != c.want {
t.Errorf("%s: abi0ArgBytes = %d, want %d", c.name, got, c.want)
}
}
}
func TestCrossChunkPullConflicts(t *testing.T) {
pulled := map[string]bool{"m/p0.Fn1": true, "m/p2.Fn9": true, "m/base.Helper": true}
spelled := map[string]bool{"m/p2.Fn9": true, "m/p0.Fn1": true, "m/p1.Fn3": true}
got := crossChunkPullConflicts(pulled, spelled)
if len(got) != 2 || got[0] != "m/p0.Fn1" || got[1] != "m/p2.Fn9" {
t.Fatalf("conflicts = %v, want [m/p0.Fn1 m/p2.Fn9]", got)
}
if got := crossChunkPullConflicts(map[string]bool{"m/base.Helper": true}, spelled); len(got) != 0 {
t.Fatalf("disjoint sets reported %v", got)
}
}