Skip to content
Closed
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
53 changes: 53 additions & 0 deletions apps/browser-demos/test/dlopen-main-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
4 changes: 3 additions & 1 deletion docs/posix-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 38 additions & 28 deletions host/src/dylink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,15 +542,22 @@ 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<number, LoadedSharedLibrary>();
private lastError: string | null = null;

constructor(options: LoadSharedLibraryOptions) {
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`
Expand All @@ -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;
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions host/src/worker-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,13 @@ function buildDlopenImports(
const imports: Record<string, WebAssembly.ExportValue> = {
__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);
Expand Down
25 changes: 22 additions & 3 deletions host/test/dlopen-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand All @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions host/test/dylink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
35 changes: 35 additions & 0 deletions host/test/fixtures/dlopen-main-scope.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#include <dlfcn.h>
#include <stdio.h>

__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;
}
23 changes: 18 additions & 5 deletions libc/glue/dlopen.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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));

Expand Down
Loading