From 06f02fa15cef9f0553eed0c19587d79e865f4128 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 00:18:49 -0400 Subject: [PATCH] dlopen: expose the main program symbol scope GNU Make and other plugin hosts use dlopen(NULL) plus RTLD_DEFAULT to resolve symbols exported by the executable. Kandelo's libc glue rejected that operation even though the host already tracked the main instance's global symbols. Reserve a stable main-program handle, resolve function exports to table indices and data exports to addresses, and preserve side-module replay handle ordering. Cover the behavior in Node and Chromium with the same compiled fixture and document export visibility requirements. This is a backward-compatible semantic addition over the existing host import signature. The ABI snapshot is unchanged, so ABI_VERSION remains 18. --- .../test/dlopen-main-scope.spec.ts | 53 +++++++++++++++ docs/posix-status.md | 4 +- docs/sdk-guide.md | 7 ++ host/src/dylink.ts | 66 +++++++++++-------- host/src/worker-main.ts | 7 ++ host/test/dlopen-e2e.test.ts | 25 ++++++- host/test/dylink.test.ts | 27 ++++++++ host/test/fixtures/dlopen-main-scope.c | 35 ++++++++++ libc/glue/dlopen.c | 23 +++++-- 9 files changed, 210 insertions(+), 37 deletions(-) create mode 100644 apps/browser-demos/test/dlopen-main-scope.spec.ts create mode 100644 host/test/fixtures/dlopen-main-scope.c diff --git a/apps/browser-demos/test/dlopen-main-scope.spec.ts b/apps/browser-demos/test/dlopen-main-scope.spec.ts new file mode 100644 index 0000000000..4371a3967d --- /dev/null +++ b/apps/browser-demos/test/dlopen-main-scope.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../../.."); +const fixtureSource = join(repoRoot, "host/test/fixtures/dlopen-main-scope.c"); +const fixtureWasm = join(tmpdir(), `kandelo-dlopen-main-${process.pid}.wasm`); + +test.beforeAll(() => { + execFileSync( + "wasm32posix-cc", + ["-O2", "-ldl", "-Wl,--export-dynamic", fixtureSource, "-o", fixtureWasm], + { cwd: repoRoot, stdio: "pipe" }, + ); +}); + +test.afterAll(() => { + rmSync(fixtureWasm, { force: true }); +}); + +test("dlopen(NULL) resolves the main program in BrowserKernel", async ({ page }) => { + await page.goto("/pages/test-runner/"); + await page.waitForFunction( + () => (window as typeof window & { __testRunnerReady?: boolean }).__testRunnerReady === true, + undefined, + { timeout: 30_000 }, + ); + + const wasm = readFileSync(fixtureWasm); + const result = await page.evaluate( + async (bytes) => { + return await (window as typeof window & { + __runTest: (wasmBytes: ArrayBuffer, argv: string[]) => Promise<{ + exitCode: number; + stdout: string; + stderr: string; + }>; + }).__runTest(new Uint8Array(bytes).buffer, ["dlopen-main-scope"]); + }, + Array.from(wasm), + ); + + expect(result).toEqual({ + exitCode: 0, + stdout: "self=42 default=8 data=35\n", + stderr: "", + combined: "self=42 default=8 data=35\n", + }); +}); diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136eef..9282b0326f 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -599,7 +599,9 @@ These PHP needs are well-handled by the current kernel: - Memory: anonymous mmap, munmap, brk - Multi-process: fork (kernel syscall), exec (host-initiated), waitpid (kernel syscall) - Networking: AF_INET TCP (connect, bind, listen, accept, send, recv), getaddrinfo -- Dynamic linking: dlopen, dlsym, dlclose, dlerror (Wasm dylink) +- Dynamic linking: dlopen (including the main-program handle), dlsym + (including RTLD_DEFAULT), dlclose, dlerror (Wasm dylink). RTLD_NEXT lookup + is not currently supported. - POSIX timers: timer_create, timer_settime, timer_gettime, timer_delete - System info: uname, sysconf, umask, getrlimit/setrlimit diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index d4d8e7bad9..84890a7aba 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -137,6 +137,13 @@ wasm32posix-cc -ldl main.c -o main.wasm wasm32posix-cc -shared -fPIC plugin.c -o plugin.so ``` +Executables that expose their own symbols to loaded modules or resolve them +through `dlopen(NULL, ...)` / `dlsym(RTLD_DEFAULT, ...)` must also link with +`-Wl,--export-dynamic`. Symbols intended for lookup must have default +visibility, either through `__attribute__((visibility("default")))` on the +public API or a scoped `-fvisibility=default` build flag. `RTLD_NEXT` lookup is +not currently supported. + ## What the SDK Does ### Compiler flags injected automatically diff --git a/host/src/dylink.ts b/host/src/dylink.ts index f159d2b8e6..1faf04569b 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -542,8 +542,9 @@ export function loadSharedLibrarySync( * dlclose API that maps to C runtime calls. */ export class DynamicLinker { + private static readonly MAIN_PROGRAM_HANDLE = 1; private options: LoadSharedLibraryOptions; - private handleCounter = 1; + private handleCounter = DynamicLinker.MAIN_PROGRAM_HANDLE + 1; private handleMap = new Map(); private lastError: string | null = null; @@ -551,6 +552,12 @@ export class DynamicLinker { this.options = options; } + /** Return the stable opaque handle used by dlopen(NULL, ...). */ + dlopenMain(): number { + this.lastError = null; + return DynamicLinker.MAIN_PROGRAM_HANDLE; + } + /** Open a shared library. Returns a handle (>0) or 0 on error. * When `replay` is provided, behaves as fork-replay: uses the parent's * saved memoryBase and skips __wasm_call_ctors. See `DylinkReplayOptions` @@ -572,32 +579,10 @@ export class DynamicLinker { } } - /** Look up a symbol by name. Returns the function or address, or null. */ - dlsym(handle: number, symbolName: string): Function | number | null { - const lib = this.handleMap.get(handle); - if (!lib) { - this.lastError = "invalid handle"; - return null; - } - - const exp = lib.exports[symbolName]; - if (exp === undefined) { - // Also check global symbol table (symbol may come from a dependency) - const global = this.options.globalSymbols.get(symbolName); - if (global === undefined) { - this.lastError = `symbol not found: ${symbolName}`; - return null; - } - if (typeof global === "function") { - this.lastError = null; - return global; - } - if (global instanceof WebAssembly.Global) { - this.lastError = null; - return global.value as number; - } - } - + private symbolAddress( + symbolName: string, + exp: Function | WebAssembly.Global | undefined, + ): number | null { if (typeof exp === "function") { // Return the table index for this function (C function pointers are table indices) const table = this.options.table; @@ -617,15 +602,40 @@ export class DynamicLinker { if (exp instanceof WebAssembly.Global) { this.lastError = null; - return (exp as WebAssembly.Global).value as number; + return Number(exp.value); } this.lastError = `symbol not found: ${symbolName}`; return null; } + /** Look up a symbol by name. Returns its function-table index or data address. */ + dlsym(handle: number, symbolName: string): number | null { + if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE || handle === 0) { + return this.symbolAddress(symbolName, this.options.globalSymbols.get(symbolName)); + } + + const lib = this.handleMap.get(handle); + if (!lib) { + this.lastError = "invalid handle"; + return null; + } + + const exp = lib.exports[symbolName]; + return this.symbolAddress( + symbolName, + typeof exp === "function" || exp instanceof WebAssembly.Global + ? exp + : this.options.globalSymbols.get(symbolName), + ); + } + /** Close a library handle. Returns 0 on success. */ dlclose(handle: number): number { + if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE) { + this.lastError = null; + return 0; + } if (!this.handleMap.has(handle)) { this.lastError = "invalid handle"; return -1; diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 9c72ac3c3d..b79c75f753 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -388,6 +388,13 @@ function buildDlopenImports( const imports: Record = { __wasm_dlopen: (bytesPtr: number, bytesLen: number, namePtr: number, nameLen: number): number => { + // dlopen(NULL, ...) asks for the main program's global symbol scope. + // No module bytes are involved; return the linker's reserved opaque + // handle while preserving the existing host-import signature. + if (bytesLen === 0 && nameLen === 0) { + return getLinker().dlopenMain(); + } + const bytes = new Uint8Array(memory.buffer, bytesPtr, bytesLen); // Copy bytes since memory.buffer may detach during Wasm instantiation const bytesCopy = new Uint8Array(bytes); diff --git a/host/test/dlopen-e2e.test.ts b/host/test/dlopen-e2e.test.ts index cd4dbee688..f4006ff199 100644 --- a/host/test/dlopen-e2e.test.ts +++ b/host/test/dlopen-e2e.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeAll } from "vitest"; import { execFileSync } from "node:child_process"; -import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -43,12 +43,12 @@ function buildSharedLib(source: string, name: string): string { } /** Build a main program with dlopen support. */ -function buildMainProgram(source: string, name: string): string { +function buildMainProgram(source: string, name: string, extraArgs: string[] = []): string { const srcPath = join(BUILD_DIR, `${name}.c`); const wasmPath = join(BUILD_DIR, `${name}.wasm`); writeFileSync(srcPath, source); execFileSync("wasm32posix-cc", - ["-O2", "-ldl", srcPath, "-o", wasmPath], + ["-O2", "-ldl", ...extraArgs, srcPath, "-o", wasmPath], { stdio: "pipe" }); return wasmPath; } @@ -66,6 +66,25 @@ describe.skipIf(!hasSysroot || !hasKernel || !hasCompiler())("dlopen end-to-end" // than the VFS layer. const io = () => new NodePlatformIO(); + it("opens and resolves the main program symbol scope", async () => { + const wasmPath = buildMainProgram( + readFileSync(join(__dirname, "fixtures", "dlopen-main-scope.c"), "utf8"), + "test-dlopen-main", + ["-Wl,--export-dynamic"], + ); + + const result = await runCentralizedProgram({ + programPath: wasmPath, + argv: ["test-dlopen-main"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, `stdout=${result.stdout}\nstderr=${result.stderr}`).toBe(0); + expect(result.stdout).toBe("self=42 default=8 data=35\n"); + expect(result.stderr).toBe(""); + }); + it("loads a shared library and calls its functions via dlopen/dlsym", { timeout: 30_000 }, async () => { // Build the shared library const soPath = buildSharedLib( diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index c9ad7bc05f..b064f8e2e5 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -301,6 +301,33 @@ describe.skipIf(!hasCompiler())("DynamicLinker", () => { expect(linker.dlclose(handle)).toBe(0); }); + it("reserves a stable handle for the main program symbol scope", () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }); + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const stackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ); + const mainData = new WebAssembly.Global({ value: "i32", mutable: false }, 0x2340); + const linker = new DynamicLinker({ + memory, + table, + stackPointer, + heapPointer: { value: 1024 }, + globalSymbols: new Map([["main_data", mainData]]), + got: new Map(), + loadedLibraries: new Map(), + }); + + const handle = linker.dlopenMain(); + expect(handle).toBeGreaterThan(0); + expect(linker.dlopenMain()).toBe(handle); + expect(linker.dlsym(handle, "main_data")).toBe(0x2340); + expect(linker.dlsym(0, "main_data")).toBe(0x2340); + expect(linker.dlclose(handle)).toBe(0); + expect(linker.dlerror()).toBeNull(); + }); + it("uses the supplied allocator for side-module memory", () => { const memory = new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }); const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); diff --git a/host/test/fixtures/dlopen-main-scope.c b/host/test/fixtures/dlopen-main-scope.c new file mode 100644 index 0000000000..7b6b991092 --- /dev/null +++ b/host/test/fixtures/dlopen-main-scope.c @@ -0,0 +1,35 @@ +#include +#include + +__attribute__((visibility("default"))) +int kandelo_main_data = 35; + +__attribute__((visibility("default"))) +int kandelo_main_function(int value) { + return value + 7; +} + +int main(void) { + void *self = dlopen(NULL, RTLD_NOW | RTLD_GLOBAL); + if (!self) { + printf("dlopen(NULL) failed: %s\n", dlerror()); + return 1; + } + + int (*function)(int) = (int (*)(int))dlsym(self, "kandelo_main_function"); + int *data = (int *)dlsym(self, "kandelo_main_data"); + int (*default_function)(int) = + (int (*)(int))dlsym(RTLD_DEFAULT, "kandelo_main_function"); + if (!function || !data || !default_function) { + printf("main dlsym failed: %s\n", dlerror()); + return 2; + } + + printf("self=%d default=%d data=%d\n", + function(35), default_function(1), *data); + if (dlclose(self) != 0) { + printf("dlclose(self) failed: %s\n", dlerror()); + return 3; + } + return 0; +} diff --git a/libc/glue/dlopen.c b/libc/glue/dlopen.c index b51df958a4..4c73bd3b28 100644 --- a/libc/glue/dlopen.c +++ b/libc/glue/dlopen.c @@ -53,10 +53,22 @@ void *dlopen(const char *path, int flags) { (void)flags; if (!path) { - /* dlopen(NULL, ...) returns a handle to the main program. - * TODO: implement RTLD_DEFAULT support. */ - set_dl_error("dlopen(NULL) not yet supported"); - return NULL; + /* An empty host request returns an opaque handle for the main + * program's global symbol scope. */ + int handle = __wasm_dlopen(NULL, 0, NULL, 0); + if (handle <= 0) { + int elen = __wasm_dlerror(dl_error_buf, (int)sizeof(dl_error_buf) - 1); + if (elen > 0) { + dl_error_buf[elen] = '\0'; + dl_error_set = 1; + } else { + set_dl_error("cannot open main program"); + } + return NULL; + } + + dl_error_set = 0; + return (void *)(long)handle; } /* Stat to get file size */ @@ -122,11 +134,12 @@ void *dlopen(const char *path, int flags) { } void *dlsym(void *handle, const char *name) { - if (!handle || !name) { + if (!name) { set_dl_error("invalid arguments to dlsym"); return NULL; } + /* A zero handle is RTLD_DEFAULT: search the main program's global scope. */ int h = (int)(long)handle; int result = __wasm_dlsym(h, name, (int)strlen(name));