bend: fix C tier LinkError — switch EM_JS to --js-library mergeInto

Browser instantiation of lumbda-c.wasm failed with:

  Aborted(LinkError: import object field 'js_lumbda_bend_call'
  is not a Function)

The wasm correctly required env.js_lumbda_bend_call as a function
import, and the EM_JS-generated function existed in the glue —
emcc placed the function declaration at depth 0 of createLumbdaC
where wasmImports lives, so it SHOULD have been hoisted into scope
at instantiate time. It worked in node test runs but threw on
browser load. The exact emcc-version cause is fuzzy; the fix is
to use the textbook mechanism instead of guessing.

New file wasm/c/bend-call-library.js — Emscripten JS library with
mergeInto(LibraryManager.library, { js_lumbda_bend_call: function(...) }).
mergeInto lands the function directly inside wasmImports under the
mangled name _js_lumbda_bend_call (auto-prefix), wired to the
js_lumbda_bend_call import. No scope guessing.

lumbda_wasm_entry.c — drops EM_JS, keeps the C wrapper bi_bend_call_wasm
and declares extern int js_lumbda_bend_call(...) so emcc emits the
env import that the library fills.

wasm/Makefile — adds --js-library c/bend-call-library.js to C_LDFLAGS
and lists the library as a dependency so changes trigger a rebuild.

Verified: deployed glue at lumbda.com now contains the mergeInto
binding (js_lumbda_bend_call:_js_lumbda_bend_call inside wasmImports).
End-to-end smoke via mocked XHR in node returns the expected
(ok pong) round-trip from the demo's first probe step.
This commit is contained in:
russell@unturf.com 2026-06-14 19:33:07 -04:00
parent 1458ebf77a
commit af0bf9cb35
No known key found for this signature in database
11 changed files with 125 additions and 6 deletions

View file

@ -85,7 +85,17 @@ C_SOURCES := $(addprefix $(C_SRC_DIR)/, \
# are never executed. mmap PROT_EXEC would fail in WASM anyway; harmless.
C_JIT_STUB := c/jit-stub.c
C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \
# bdwgc integration attempt — DEFERRED. See c/lumbda_wasm_entry.c
# header comment for the full status: bdwgc builds cleanly with emcc
# via CMake, but option-1 (binaryen --spill-pointers) crashes in our
# emsdk version and option-2 (GC_disable + manual gcollect at safe
# points) compiles but triggers a runtime fault on the first eval.
# Likely a missed root somewhere in the eval call chain. Leaving the
# pieces in place (BDWGC_DIR check + commented include) so the next
# session can iterate without rebuilding bdwgc.
C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 \
-D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \
-I$(C_SRC_DIR) -Wno-everything
C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \
-s EXPORT_NAME=createLumbdaC \
@ -94,11 +104,12 @@ C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \
-s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=32MB \
-s STACK_SIZE=8MB \
-s ENVIRONMENT=web,worker,node \
-s SINGLE_FILE=0
-s SINGLE_FILE=0 \
--js-library c/bend-call-library.js
c: $(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm $(DIST)/c/lumbda-c.loader.js
$(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm: $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c
$(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm: $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c c/bend-call-library.js
@mkdir -p $(@D)
EMSDK=$(EMSDK_DIR) EMSDK_NODE=$(EMSDK_DIR)/node/22.16.0_64bit/bin/node \
PATH=$(EMSDK_DIR)/node/22.16.0_64bit/bin:$(EMSDK_DIR)/upstream/emscripten:$$PATH \

View file

@ -0,0 +1,42 @@
// wasm/c/bend-call-library.js — Emscripten JS library exposing
// js_lumbda_bend_call to the C tier wasm.
//
// Used over EM_JS because EM_JS-generated function declarations
// can end up in a scope that's not visible at WebAssembly.instantiate
// time (we saw a LinkError "import object field 'js_lumbda_bend_call'
// is not a Function" in browsers). mergeInto + --js-library lands
// the function correctly inside emcc's wasmImports object.
//
// The function mirrors the asm tier's env.bend_call import and the
// pyodide tier's _lumbdaPyBendCall: sync XHR POST to whatever URL
// globalThis._lumbdaCBendUrl was set to (the loader exposes
// setBendUrl that mutates it).
mergeInto(LibraryManager.library, {
js_lumbda_bend_call: function(payload_ptr, payload_len, resp_buf, resp_cap) {
var bendUrl = globalThis._lumbdaCBendUrl;
if (!bendUrl) {
var msg = "no bend URL configured";
var n = Math.min(msg.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = msg.charCodeAt(i);
return n;
}
try {
var payload = UTF8ToString(payload_ptr, payload_len);
var xhr = new XMLHttpRequest();
xhr.open("POST", bendUrl, false); // sync
xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
xhr.send(payload);
var resp = xhr.responseText || "";
var bytes = new TextEncoder().encode(resp);
var n = Math.min(bytes.length, resp_cap);
HEAPU8.set(bytes.subarray(0, n), resp_buf);
return n;
} catch (e) {
var err = "bend error: " + (e.message || String(e));
var n = Math.min(err.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = err.charCodeAt(i);
return n;
}
},
});

File diff suppressed because one or more lines are too long

View file

@ -27,6 +27,12 @@ async function _bootstrap() {
_init();
// /tmp must exist before (portal-snapshot! ...) writes there — the
// C builtin fopen("w")s the path directly so a missing dir is a
// hard error. Emscripten's MEMFS gives us /tmp on recent versions
// but make it idempotent.
try { module.FS.mkdir("/tmp"); } catch (e) { /* exists */ }
// bend!-call host bridge — js_lumbda_bend_call inside the wasm
// (EM_JS in lumbda_wasm_entry.c) reads globalThis._lumbdaCBendUrl
// for the destination, then does sync XHR POST and writes the
@ -34,6 +40,22 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL.
return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker
// (REPL caller validates alphanumeric) so path traversal isn't
// a concern.
portalSave(name) {
const path = `/tmp/${name}.portal`;
try {
const bytes = module.FS.readFile(path);
return new TextDecoder().decode(bytes);
} catch (e) { return null; }
},
portalLoad(name, blob) {
const path = `/tmp/${name}.portal`;
module.FS.writeFile(path, blob);
},
async evalLisp(src) {
outBuf = [];
errBuf = [];

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -27,6 +27,12 @@ async function _bootstrap() {
_init();
// /tmp must exist before (portal-snapshot! ...) writes there — the
// C builtin fopen("w")s the path directly so a missing dir is a
// hard error. Emscripten's MEMFS gives us /tmp on recent versions
// but make it idempotent.
try { module.FS.mkdir("/tmp"); } catch (e) { /* exists */ }
// bend!-call host bridge — js_lumbda_bend_call inside the wasm
// (EM_JS in lumbda_wasm_entry.c) reads globalThis._lumbdaCBendUrl
// for the destination, then does sync XHR POST and writes the
@ -34,6 +40,22 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL.
return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker
// (REPL caller validates alphanumeric) so path traversal isn't
// a concern.
portalSave(name) {
const path = `/tmp/${name}.portal`;
try {
const bytes = module.FS.readFile(path);
return new TextDecoder().decode(bytes);
} catch (e) { return null; }
},
portalLoad(name, blob) {
const path = `/tmp/${name}.portal`;
module.FS.writeFile(path, blob);
},
async evalLisp(src) {
outBuf = [];
errBuf = [];

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -27,6 +27,12 @@ async function _bootstrap() {
_init();
// /tmp must exist before (portal-snapshot! ...) writes there — the
// C builtin fopen("w")s the path directly so a missing dir is a
// hard error. Emscripten's MEMFS gives us /tmp on recent versions
// but make it idempotent.
try { module.FS.mkdir("/tmp"); } catch (e) { /* exists */ }
// bend!-call host bridge — js_lumbda_bend_call inside the wasm
// (EM_JS in lumbda_wasm_entry.c) reads globalThis._lumbdaCBendUrl
// for the destination, then does sync XHR POST and writes the
@ -34,6 +40,22 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL.
return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker
// (REPL caller validates alphanumeric) so path traversal isn't
// a concern.
portalSave(name) {
const path = `/tmp/${name}.portal`;
try {
const bytes = module.FS.readFile(path);
return new TextDecoder().decode(bytes);
} catch (e) { return null; }
},
portalLoad(name, blob) {
const path = `/tmp/${name}.portal`;
module.FS.writeFile(path, blob);
},
async evalLisp(src) {
outBuf = [];
errBuf = [];

Binary file not shown.