Summary
cwrap(ident, "boolean", argTypes) returns a JS number (0 or 1) instead of a boolean when argTypes consists only of "number" / "boolean". This is inconsistent with ccall(ident, "boolean", argTypes, args), which correctly returns a JS boolean. The cwrap docs list "boolean" as a valid return type, so the fast-path skip is a silent contract violation.
Cause
In src/library_ccall.js, cwrap's fast-path test treats "boolean" returns as numeric:
var cwrap = (ident, returnType, argTypes, opts) => {
var numericArgs = !argTypes || argTypes.every((type) => type === "number" || type === "boolean");
var numericRet = returnType !== "string"; // ← "boolean" counted as numeric
if (numericRet && numericArgs && !opts) return getCFunc(ident);
return (...args) => ccall(ident, returnType, argTypes, args, opts);
};
When the fast path triggers, getCFunc(ident) is the raw WASM export, so Boolean(ret) from ccall's convertReturnValue is never applied.
The asymmetry traces back to #17511, which extended the fast path to accept "boolean" as an argument type but didn't update the return-type check correspondingly.
Reproduction
repro.c:
#include <stdbool.h>
bool some_bool_func(int x) {
return x != 0;
}
Build:
emcc repro.c -o repro.mjs \
-sMODULARIZE=1 -sEXPORT_ES6=1 \
-sENVIRONMENT=node \
-sEXPORTED_FUNCTIONS='["_some_bool_func"]' \
-sEXPORTED_RUNTIME_METHODS='["cwrap","ccall"]'
test.mjs:
import createModule from "./repro.mjs";
const mod = await createModule();
const viaCwrap = mod.cwrap("some_bool_func", "boolean", ["number"]);
const r = viaCwrap(1);
console.log("cwrap result:", r, "typeof:", typeof r);
console.log("ccall result:", mod.ccall("some_bool_func", "boolean", ["number"], [1]));
Run: node test.mjs
Actual:
cwrap result: 1 typeof: number
ccall result: true
Expected:
cwrap result: true typeof: boolean
ccall result: true
Impact
For plain-JS callers, if (result) truthiness coercion masks the bug. It surfaces for strictly-typed consumers (Scala.js, Kotlin/JS, TypeScript when the cwrap return is annotated boolean) — the value crosses the JS↔host boundary as a number and the host's type check rejects the cast.
Possible fixes
- Exclude
"boolean" from the fast-path return check: var numericRet = returnType !== "string" && returnType !== "boolean"; — falls through to ccall, which already does Boolean(ret). Smallest diff, correctness wins.
- Keep the fast path but wrap with a
Boolean() thunk when returnType === "boolean". Preserves the per-call perf win.
- Document
"boolean" as unsupported as a cwrap return type (only valid via ccall).
Summary
cwrap(ident, "boolean", argTypes)returns a JSnumber(0 or 1) instead of abooleanwhenargTypesconsists only of"number"/"boolean". This is inconsistent withccall(ident, "boolean", argTypes, args), which correctly returns a JSboolean. Thecwrapdocs list"boolean"as a valid return type, so the fast-path skip is a silent contract violation.Cause
In
src/library_ccall.js,cwrap's fast-path test treats"boolean"returns as numeric:When the fast path triggers,
getCFunc(ident)is the raw WASM export, soBoolean(ret)fromccall'sconvertReturnValueis never applied.The asymmetry traces back to #17511, which extended the fast path to accept
"boolean"as an argument type but didn't update the return-type check correspondingly.Reproduction
repro.c:Build:
test.mjs:Run:
node test.mjsActual:
Expected:
Impact
For plain-JS callers,
if (result)truthiness coercion masks the bug. It surfaces for strictly-typed consumers (Scala.js, Kotlin/JS, TypeScript when thecwrapreturn is annotatedboolean) — the value crosses the JS↔host boundary as a number and the host's type check rejects the cast.Possible fixes
"boolean"from the fast-path return check:var numericRet = returnType !== "string" && returnType !== "boolean";— falls through toccall, which already doesBoolean(ret). Smallest diff, correctness wins.Boolean()thunk whenreturnType === "boolean". Preserves the per-call perf win."boolean"as unsupported as acwrapreturn type (only valid viaccall).