Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,19 @@ jobs:
dev/build_ci_tools.sh "$RUNNER_TEMP/llgo-bin"
echo "LLGO_ROOT=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"

- name: Test reflected method value calls
if: matrix.suite == 'runtime'
shell: bash
env:
LLGO: ${{ runner.temp }}/llgo-bin/llgo
LLGO_BUILD_CACHE: "1"
run: |
for fixture in reflect-method-value reflect-method-alias; do
for target in wasi emscripten emscripten-memory64; do
"$LLGO" run -O2 -target "$target" -emulator "./test/wasm/$fixture"
done
done

- name: Compare WebAssembly EH encodings and Go/C++ boundary
if: matrix.suite == 'runtime'
timeout-minutes: 10
Expand Down Expand Up @@ -869,6 +882,16 @@ jobs:
python3 -m unittest discover -s dev -p test_wasm_debug_info_test.py
python3 dev/test_wasm_debug_info.py --profile j32 --profile j64 --profile w32 --artifact embedded --artifact external

- name: Test optimized Emscripten runtime boundaries
if: matrix.suite == 'runtime'
env:
LLGO: ${{ runner.temp }}/llgo-bin/llgo
LLGO_BUILD_CACHE: "1"
CHROME: ${{ steps.setup-chrome.outputs.chrome-path }}
run: |
node --test test/wasm/optimization/asyncify-exports.test.mjs
python3 test/wasm/optimization/test.py --browser

- name: Build standard runtime for wasm
if: matrix.suite == 'runtime'
shell: bash
Expand Down
25 changes: 21 additions & 4 deletions dev/test_wasm_browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import http from "node:http";
import path from "node:path";
import process from "node:process";

if (process.argv.length !== 4) {
throw new Error("usage: node test_wasm_browser.mjs <module.mjs> <expected-output>");
if (process.argv.length < 4) {
throw new Error("usage: node test_wasm_browser.mjs <module.mjs> <expected-output> [program-args...]");
}

const modulePath = path.resolve(process.argv[2]);
const expected = process.argv[3];
const programArgs = process.argv.slice(4);
const requireExit = process.env.LLGO_BROWSER_REQUIRE_EXIT === "1";
const root = path.dirname(modulePath);
const moduleName = path.basename(modulePath);
const browserProfile = path.join(root, `.chrome-profile-${process.pid}`);
Expand Down Expand Up @@ -44,7 +46,9 @@ function page() {
const result = document.querySelector("#result");
const output = [];
const expected = ${JSON.stringify(expected)};
const requireExit = ${JSON.stringify(requireExit)};
let finished = false;
let successfulExit = false;
const finish = (status, detail) => {
if (finished) return;
finished = true;
Expand All @@ -55,7 +59,7 @@ const finish = (status, detail) => {
const write = value => {
output.push(String(value));
result.textContent = output.join("\\n");
if (result.textContent.includes(expected)) finish("success", result.textContent);
if (result.textContent.includes(expected) && (!requireExit || successfulExit)) finish("success", result.textContent);
};
for (const method of ["log", "info", "warn", "error"]) {
const original = console[method].bind(console);
Expand All @@ -66,7 +70,18 @@ for (const method of ["log", "info", "warn", "error"]) {
}
try {
const loaded = await import(${JSON.stringify(`/${moduleName}`)});
await loaded.default({ print: write, printErr: write });
await loaded.default({
arguments: ${JSON.stringify(programArgs)}, print: write, printErr: write,
onAbort(reason) { finish("failure", String(reason)); },
onExit(status) {
if (status !== 0) {
finish("failure", "exit " + status + ": " + result.textContent);
return;
}
successfulExit = true;
if (result.textContent.includes(expected)) finish("success", result.textContent);
},
});
} catch (error) {
write(error?.stack || error);
finish("failure", result.textContent);
Expand All @@ -79,6 +94,8 @@ setTimeout(() => {

const server = http.createServer(async (request, response) => {
try {
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
const url = new URL(request.url, "http://localhost");
if (url.pathname === "/__result") {
const status = url.searchParams.get("status");
Expand Down
5 changes: 5 additions & 0 deletions dev/test_wasm_wasi_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ def main():
run_probe(env, directory, "threaded-gc", "wasm-wasi-threaded-gc",
"", "wasi threaded gc ok", 180)
run_arena_boundaries(env, directory)
for _ in range(2):
# The second build exercises the bridge-enabled package cache.
run_llgo(env, ["run", "-target", "wasi", "-emulator",
str(ROOT / "test/wasm/reflect-method-value")],
"wasm reflect method values: 42 42 42 42 42")
run_llgo(env, ["run", "-target", "wasi", "-emulator",
str(ROOT / "internal/build/testdata/wasm-wasi-threads")],
"wasi threads ok")
Expand Down
3 changes: 3 additions & 0 deletions dev/test_wasm_workers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ run_browser_acceptance() {
local module
for module in workers-emscripten.mjs workers-memory64.mjs hardening-workers-emscripten.mjs hardening-workers-memory64.mjs \
fs-single-emscripten.mjs fs-workers-emscripten.mjs fs-single-emscripten-memory64.mjs fs-workers-emscripten-memory64.mjs; do
echo "browser worker acceptance: ${module}"
run_with_timeout "${node_cmd}" "${worker_fixture}/browser-runner.mjs" \
"${browser}" "http://127.0.0.1:8123/browser.html?module=${module}"
done
Expand All @@ -165,6 +166,8 @@ require_tool "${wasm_opt_cmd}"
require_tool curl
export WASMOPT="${wasm_opt_cmd}"

"${node_cmd}" --test "${repo_root}/test/wasm/workers/browser-process.test.mjs"

# Preserve the single-worker R2 behavior while adding the worker backend.
run_single_hardening emscripten emscripten-runner.mjs hardening-single-emscripten
run_single_hardening emscripten-memory64 emscripten-memory64-runner.mjs hardening-single-memory64
Expand Down
10 changes: 10 additions & 0 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,14 @@ func configureWasmWorkers(conf *Config, export *crosscompile.Export) (wasmworker
if err := config.ValidateTarget(conf.Goos, conf.Goarch, export.WasmProfile, export.WasmProvider); err != nil {
return config, err
}
// GoJS and Emscripten providers both currently use emcc+Asyncify. Bind
// this compatibility shim to the actual link contract, so a future JSPI
// or non-Emscripten provider does not require Asyncify control exports.
if conf.Goos == "js" && conf.Goarch == "wasm" && slices.Contains(export.LDFLAGS, "-sASYNCIFY=1") {
export.LDFLAGS = append(export.LDFLAGS,
"--pre-js", filepath.Join(env.LLGoROOT(), "targets", "emscripten-asyncify-exports.js"),
)
}
if conf.Goos == "js" && export.WasmProvider == crosscompile.WasmProviderEmscripten {
export.LDFLAGS = append(export.LDFLAGS,
"--pre-js", filepath.Join(env.LLGoROOT(), "targets", "wasm_fs.js"),
Expand Down Expand Up @@ -3397,13 +3405,15 @@ func printCompletedPackage(conf *Config, pkg *aPackage) {

func exportObject(ctx *context, pkgPath string, exportFile string, pkg llssa.Package) (string, error) {
applySizeOptimizationAttributes(pkg.Module(), ctx.buildConf.OptLevel)
applyEmscriptenEHFeature(ctx, pkg.Module())
if useInMemoryNativeCodegen(ctx) {
return exportObjectInMemory(ctx, pkgPath, exportFile, pkg)
}
return exportObjectWithClang(ctx, pkgPath, exportFile, []byte(pkg.String()))
}

func exportPackageObject(ctx *context, pkgPath string, exportFile string, pkg llssa.Package) (string, packageArchiveBuffer, error) {
applyEmscriptenEHFeature(ctx, pkg.Module())
if !useInMemoryNativeCodegen(ctx) {
path, err := exportObjectWithClang(ctx, pkgPath, exportFile, []byte(pkg.String()))
return path, packageArchiveBuffer{}, err
Expand Down
27 changes: 27 additions & 0 deletions internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,33 @@ func TestUsesSingleWorkerWasmScheduler(t *testing.T) {
}
}

func TestConfigureWasmAsyncifyExports(t *testing.T) {
t.Setenv(llgoWasmWorkers, "1")
for _, tc := range []struct {
name string
provider crosscompile.WasmProvider
flags []string
want bool
}{
{"Emscripten Asyncify", crosscompile.WasmProviderEmscripten, []string{"-sASYNCIFY=1"}, true},
{"GoJS Asyncify", crosscompile.WasmProviderGoJS, []string{"-sASYNCIFY=1"}, true},
{"Emscripten without Asyncify", crosscompile.WasmProviderEmscripten, nil, false},
{"GoJS without Asyncify", crosscompile.WasmProviderGoJS, nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
conf := &Config{Goos: "js", Goarch: "wasm"}
export := crosscompile.Export{WasmProfile: crosscompile.WasmProfileJ32, WasmProvider: tc.provider, LDFLAGS: tc.flags}
if _, err := configureWasmWorkers(conf, &export); err != nil {
t.Fatal(err)
}
shim := filepath.Join(env.LLGoROOT(), "targets", "emscripten-asyncify-exports.js")
if got := slices.Contains(export.LDFLAGS, shim); got != tc.want {
t.Fatalf("Asyncify shim present = %v, want %v; flags %v", got, tc.want, export.LDFLAGS)
}
})
}
}

func TestConfigureWasmWorkers(t *testing.T) {
t.Setenv(llgoWasmWorkers, "2")
conf := Config{Goos: "js", Goarch: "wasm"}
Expand Down
5 changes: 5 additions & 0 deletions internal/build/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ func (c *context) collectEnvInputs(m *manifestBuilder) {
llgoStdioNobuf,
llgoFullRpath,
}
if c.buildConf.Goos == "js" && c.buildConf.Goarch == "wasm" {
// emcc consumes this for both LLVM IR codegen and linking. In
// particular, native Wasm SjLj objects cannot reuse JS SjLj objects.
envVars = append(envVars, "EMCC_CFLAGS")
}
for _, envVar := range envVars {
if v := os.Getenv(envVar); v != "" {
m.env.Vars = m.env.Vars.Add(envVar, v)
Expand Down
2 changes: 1 addition & 1 deletion internal/build/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func runNativeTest(commands commandEnv, program testProgram, conf *Config, stdou
return nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
if !program.coverage {
if !program.coverage || !exitErr.Exited() {
if exitErr.Exited() {
fmt.Fprintf(stderr, "%s: exit code %d\n", program.app, exitErr.ExitCode())
} else {
Expand Down
30 changes: 16 additions & 14 deletions internal/build/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,20 +464,22 @@ func TestRunNativeTest(t *testing.T) {
})

t.Run("signal exit", func(t *testing.T) {
var stderr bytes.Buffer
conf := &Config{RunArgs: append(args, "signal")}
program := testProgram{app: executable, pkgDir: t.TempDir(), pkgName: "signal"}
err := runNativeTest(commands, program, conf, io.Discard, &stderr)
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("runNativeTest error = %v, want signal termination", err)
}
status, ok := exitErr.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() || status.Signal() != syscall.SIGKILL {
t.Fatalf("process status = %v, want SIGKILL", exitErr.Sys())
}
if got := stderr.String(); !strings.Contains(got, "killed") || strings.Contains(got, "exit code") {
t.Fatalf("stderr = %q, want signal reason without an exit code", got)
for _, coverage := range []bool{false, true} {
var stderr bytes.Buffer
conf := &Config{RunArgs: append(args, "signal")}
program := testProgram{app: executable, pkgDir: t.TempDir(), pkgName: "signal", coverage: coverage}
err := runNativeTest(commands, program, conf, io.Discard, &stderr)
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("coverage=%v: runNativeTest error = %v, want signal termination", coverage, err)
}
status, ok := exitErr.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() || status.Signal() != syscall.SIGKILL {
t.Fatalf("coverage=%v: process status = %v, want SIGKILL", coverage, exitErr.Sys())
}
if got := stderr.String(); !strings.Contains(got, "killed") || strings.Contains(got, "exit code") {
t.Fatalf("coverage=%v: stderr = %q, want signal reason without an exit code", coverage, got)
}
}
})

Expand Down
37 changes: 37 additions & 0 deletions internal/build/testdata/wasm-workers/browser-process.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { spawn } from "node:child_process";

export function spawnBrowser(command, args) {
// Each invocation owns a separate process group. A Chrome launcher can exit
// before its renderer processes, which may still hold the stderr pipe open.
const processGroup = process.platform !== "win32";
const child = spawn(command, args, {
detached: processGroup,
stdio: ["ignore", "ignore", "pipe"],
});
let stopped = false;
return {
child,
stop() {
if (stopped) return;
stopped = true;
try {
if (child.pid !== undefined) {
if (processGroup) {
try {
process.kill(-child.pid, "SIGKILL");
} catch (error) {
if (error.code !== "ESRCH") throw error;
}
} else {
child.kill("SIGKILL");
}
}
} finally {
// Do not wait for a descendant to close inherited descriptors or for
// the launcher to emit another event after it has already exited.
child.stderr.destroy();
child.unref();
}
},
};
}
16 changes: 4 additions & 12 deletions internal/build/testdata/wasm-workers/browser-runner.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import { spawnBrowser } from "./browser-process.mjs";

const [browser, url] = process.argv.slice(2);
if (!browser || !url) {
Expand All @@ -12,7 +12,7 @@ if (!browser || !url) {
// worker even after the Node acceptance run has passed.
const deadline = Date.now() + 90_000;
const profile = await mkdtemp(join(tmpdir(), "llgo-wasm-chrome-"));
const chrome = spawn(browser, [
const { child: chrome, stop: stopChrome } = spawnBrowser(browser, [
"--headless=new",
"--no-sandbox",
"--disable-gpu",
Expand All @@ -21,8 +21,7 @@ const chrome = spawn(browser, [
`--user-data-dir=${profile}`,
"--remote-debugging-port=0",
"about:blank",
], { stdio: ["ignore", "ignore", "pipe"] });
const chromeExit = new Promise(resolve => chrome.once("exit", resolve));
]);

let chromeLog = "";
chrome.stderr.setEncoding("utf8");
Expand Down Expand Up @@ -150,14 +149,7 @@ try {
process.exitCode = 1;
} finally {
client?.socket.close();
if (chrome.exitCode === null && chrome.signalCode === null) {
chrome.kill("SIGTERM");
await Promise.race([chromeExit, delay(2_000)]);
if (chrome.exitCode === null && chrome.signalCode === null) {
chrome.kill("SIGKILL");
await chromeExit;
}
}
stopChrome();
// Chrome's child processes can still flush profile files after its main
// process exits. Node retries ENOTEMPTY for a recursive removal here.
await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
Expand Down
46 changes: 46 additions & 0 deletions internal/build/wasm_eh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package build

import (
"slices"
"strings"

"github.com/xgo-dev/llgo/internal/crosscompile"
"github.com/xgo-dev/llvm"
)

// Emscripten can select native Wasm SjLj through SUPPORT_LONGJMP=wasm. LLVM
// requires the feature on each caller's IR function, even when emcc receives
// -fwasm-exceptions. Declaring the capability does not select the EH mode:
// Emscripten's default JS SjLj and explicit native SjLj keep their own lowering.
func applyEmscriptenEHFeature(ctx *context, mod llvm.Module) {
profile := ctx.crossCompile.WasmProfile
if profile != crosscompile.WasmProfileJ32 && profile != crosscompile.WasmProfileJ64 {
return
}
required := []string{"exception-handling"}
if slices.Contains(ctx.crossCompile.CCFLAGS, "-pthread") {
// Function attributes override the backend's command-line features.
// Preserve the capabilities selected by emcc -pthread: shared-memory
// objects require both, and TLS accesses require atomics lowering.
required = append(required, "atomics", "bulk-memory")
}
for fn := mod.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) {
if fn.IsDeclaration() {
continue
}
features := make([]string, 0, len(required))
for _, feature := range required {
features = append(features, "+"+feature)
}
for _, attr := range fn.GetFunctionAttributes() {
if attr.IsString() && attr.GetStringKind() == "target-features" {
for _, feature := range strings.Split(attr.GetStringValue(), ",") {
if feature != "" && !slices.Contains(required, strings.TrimLeft(feature, "+-")) {
features = append(features, feature)
}
}
}
}
fn.AddFunctionAttr(mod.Context().CreateStringAttribute("target-features", strings.Join(features, ",")))
}
}
Loading
Loading