From 850d733efed888130974e17d4001fdaeaffa405c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 11 Nov 2026 18:21:07 +0000 Subject: [PATCH 1/2] wasm32 and wasm64: the default library's borrow facts, -mm=gc refused, a JS host and test runs - ObjDumper: a wasm data symbol is an offset into its data segment, but it was read at that offset into the whole DATA section (segment headers first), so an own program linked with a wasm default library got none of its borrow facts and failed to compile. - -mm=gc (the default) for a wasm executable or library is an error that says so: the collector has no wasm build. It used to fail on a default library folder that is never built. - docs/how/wasm/test: host.mjs implements what a wasm program imports from `env` (the C library, the default library's wrappers: printf, strtod, malloc, regex, time, stdio, process/fs under Node), for Node (run.mjs) and the browser (index.html). run_tests.mjs compiles and runs the tester's single-file tests (--no-default-lib, as test-runner) and the default library's tests (linked with the wasm default library) for wasm32 and wasm64 under none, rc and own. Skipped, each with its reason: try/catch (does not catch on wasm yet), async, threads, and wasm_skips.txt, which lists the wasm compiler gaps still open. Co-Authored-By: Claude Opus 5.5 --- docs/how/wasm/test/.gitignore | 2 + docs/how/wasm/test/README.md | 60 ++ docs/how/wasm/test/host.mjs | 906 ++++++++++++++++++++++++++++ docs/how/wasm/test/index.html | 180 ++---- docs/how/wasm/test/run.mjs | 43 ++ docs/how/wasm/test/run_tests.mjs | 283 +++++++++ docs/how/wasm/test/wasm_skips.txt | 28 + tslang/lib/TypeScript/ObjDumper.cpp | 31 + tslang/tslang/tslang.cpp | 12 + 9 files changed, 1399 insertions(+), 146 deletions(-) create mode 100644 docs/how/wasm/test/.gitignore create mode 100644 docs/how/wasm/test/README.md create mode 100644 docs/how/wasm/test/host.mjs create mode 100644 docs/how/wasm/test/run.mjs create mode 100644 docs/how/wasm/test/run_tests.mjs create mode 100644 docs/how/wasm/test/wasm_skips.txt diff --git a/docs/how/wasm/test/.gitignore b/docs/how/wasm/test/.gitignore new file mode 100644 index 000000000..39ce2cd40 --- /dev/null +++ b/docs/how/wasm/test/.gitignore @@ -0,0 +1,2 @@ +out*/ +wasm/ diff --git a/docs/how/wasm/test/README.md b/docs/how/wasm/test/README.md new file mode 100644 index 000000000..7936309e1 --- /dev/null +++ b/docs/how/wasm/test/README.md @@ -0,0 +1,60 @@ +# Running tslang programs and tests as WebAssembly + +`wasm32-unknown-unknown` and `wasm64-unknown-unknown` have no C library. tslang links a wasm program +with `--allow-undefined`, so whatever the C library and the default library's C++ wrappers provide on +other targets (`malloc`, `snprintf`, `puts`, `regexp_*`, `stdio_write`, the time functions, ...) is an +import from the `env` module. [host.mjs](host.mjs) implements them in JavaScript, for Node and for +the browser. + +## The default library + +Built by the default library's `scripts\build_wasm.bat` (Windows) or `scripts/build_wasm.sh` (Linux) +for `-mm=none`, `-mm=rc` and `-mm=own`, release and debug: + + __build\defaultlib\lib\\unknown\unknown\unknown\\\libTypeScriptDefaultLib.a + +It is `lib.ts` plus the OS layer `lib.wasm.ts`, with no C++ wrappers. `-mm=gc` (tslang's default) is +an error for a wasm triple, because the collector has no wasm build. Pass `-mm` explicitly. + +## One program + + tslang --emit=exe -mm=rc --opt -mtriple=wasm32-unknown-unknown --default-lib-path=<...>\__build -o=hello.wasm hello.ts + node run.mjs hello.wasm + +The exit code is the program's own (`main`'s result, or `exit()`'s argument), or 1 for a trap or an +uncaught exception. + +wasm64 needs Node 24 or later. LLVM gives a wasm64 module 64-bit tables, which older V8 cannot +instantiate. + +## The tests + + node run_tests.mjs [--suite=tester,defaultlib] [--triple=wasm32,wasm64] [--mm=none,rc,own] [--debug] [-j] [filter] + +For each triple and model, this compiles the tests and runs each program under Node. The summary +and every failure are written to `out/results.txt`. + +- `tester`: every single-file test the tester's CMakeLists.txt runs, built as test-runner builds them + (`--no-default-lib`). A test passes when it exits with 0 and prints `done.`. +- `defaultlib`: the default library's own tests (`TypeScriptCompilerDefaultLib/tests`), linked with + the wasm default library and run in that repository, with `tests/.stdin` as standard input. + A test passes when it prints `ALL DONE`, writes nothing to stderr and exits with 0, as `tests.ps1` + checks. + +Some tests are skipped, each with its reason: + +- tests that catch an exception (`try`/`catch` does not catch on wasm yet; a throw ends the program) +- async tests (the async runtime has no wasm build) +- tests that use threads +- default-library tests marked `// gc only`, `// linux only`, or (under own) `// not own` +- the tests in [wasm_skips.txt](wasm_skips.txt) + +Under `-mm=own`, a test that doesn't compile for wasm is compiled again natively. If that fails too, +the test is listed as NATIVE and not counted as a failure: it is something `-mm=own` cannot prove +yet. Native ctest runs only a few tests under own, but the whole corpus under none and rc, so under +those two models every wasm compile error counts as a failure. + +## In a browser + +Put the `.wasm` files in `wasm\`, serve this folder over HTTP (e.g. `npx http-server .`) and open +[index.html](index.html). It runs each program with the same host. diff --git a/docs/how/wasm/test/host.mjs b/docs/how/wasm/test/host.mjs new file mode 100644 index 000000000..29ec1b35e --- /dev/null +++ b/docs/how/wasm/test/host.mjs @@ -0,0 +1,906 @@ +// The host of a tslang wasm program (wasm32-unknown-unknown or wasm64-unknown-unknown). +// +// These targets have no C library. tslang links a wasm program with --allow-undefined, so what the C +// library and the default library's C++ wrappers provide on other targets (malloc, snprintf, puts, +// regexp_*, stdio_write, the time functions, ...) are imports from the `env` module, implemented +// here in JavaScript. One module for Node (run.mjs) and the browser (index.html): Node-only parts +// (stdin, fs, process) are used only when `node` is passed in. +// +// Pointers and size_t are i32 on wasm32 and i64 on wasm64, which JavaScript sees as a Number and a +// BigInt. Every import takes them through N() and returns them through P(); C's `long long` (time_t, +// tslang's `long`) is an i64 on both and so always a BigInt. +// +// const host = createHost({ args, write, node }); +// const { instance } = await WebAssembly.instantiate(bytes, host.imports); +// const exitCode = host.run(instance); + +export class ExitSignal extends Error +{ + constructor(code) + { + super(`exit(${code})`); + this.code = code; + } +} + +// a C++ exception nothing caught: on wasm try/catch does not catch yet, so every throw ends here +export class UncaughtThrow extends Error +{ + constructor(objectAddress) + { + super("uncaught exception"); + this.objectAddress = objectAddress; + } +} + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +export function createHost(options = {}) +{ + const args = options.args ?? ["program.wasm"]; + const write = options.write ?? ((fd, text) => (fd === 2 ? console.error : console.log)(text)); + const node = options.node; // { fs, process } under Node + + let instance; + let memory; + let is64 = false; + + const N = (v) => Number(v); + const P = (v) => (is64 ? BigInt(v) : v); + + const u8 = () => new Uint8Array(memory.buffer); + const dv = () => new DataView(memory.buffer); + + const utf8Decoder = new TextDecoder("utf-8"); + const utf8Encoder = new TextEncoder(); + + const cstrEnd = (p) => { const m = u8(); while (m[p] !== 0) p++; return p; }; + const bytesOf = (p, n) => u8().slice(p, p + n); + const readStr = (p) => { p = N(p); return p === 0 ? "" : utf8Decoder.decode(u8().subarray(p, cstrEnd(p))); }; + // one char per byte: the regex wrappers count positions in bytes, as std::cmatch does over a char* + const readLatin1 = (p) => { p = N(p); let s = ""; const m = u8(); for (let e = cstrEnd(p); p < e; p++) s += String.fromCharCode(m[p]); return s; }; + const latin1Bytes = (s) => { const b = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0xff; return b; }; + // `bytes` and a 0 at `p`, at most `max` bytes with the 0 + const writeBytesZ = (p, bytes, max = Infinity) => + { + p = N(p); + if (max <= 0) return 0; + const n = Math.min(bytes.length, max - 1); + u8().set(bytes.subarray(0, n), p); + u8()[p + n] = 0; + return n; + }; + const writeStrZ = (p, s, max) => writeBytesZ(p, utf8Encoder.encode(s), max); + + // ---- the heap: free lists per 16-byte size class above a bump pointer, the memory grown as needed ---- + + const ALIGN = 16; + let heapTop = 0; + const sizes = new Map(); + const freeLists = new Map(); + + const ensure = (end) => + { + const have = memory.buffer.byteLength; + if (end <= have) return; + const pages = Math.ceil((end - have) / 65536); + memory.grow(is64 ? BigInt(pages) : pages); + }; + + const malloc = (size) => + { + size = Math.max(ALIGN, Math.ceil(N(size) / ALIGN) * ALIGN); + const list = freeLists.get(size); + let p; + if (list && list.length) + { + p = list.pop(); + } + else + { + p = heapTop; + heapTop += size; + ensure(heapTop); + } + + sizes.set(p, size); + return p; + }; + + const free = (p) => + { + p = N(p); + if (p === 0) return; + const size = sizes.get(p); + if (size === undefined) throw new Error(`free of ${p}: not a block the heap gave out`); + sizes.delete(p); + let list = freeLists.get(size); + if (!list) freeLists.set(size, list = []); + list.push(p); + }; + + const realloc = (p, size) => + { + p = N(p); + size = N(size); + if (p === 0) return malloc(size); + const old = sizes.get(p); + if (old === undefined) throw new Error(`realloc of ${p}: not a block the heap gave out`); + if (size <= old) return p; + const q = malloc(size); + u8().copyWithin(q, p, p + old); + free(p); + return q; + }; + + const calloc = (count, size) => + { + const n = N(count) * N(size); + const p = malloc(n); + u8().fill(0, p, p + n); + return p; + }; + + // ---- printf ---- + + // C's %e: at least two exponent digits + const expForm = (v, prec, upper) => + { + let s = v.toExponential(prec).replace(/e([+-])(\d)$/, "e$10$2"); + return upper ? s.toUpperCase() : s; + }; + + const stripZeros = (s) => + { + if (s.indexOf(".") < 0) return s; + const e = s.search(/[eE]/); + let mantissa = e < 0 ? s : s.slice(0, e); + const exp = e < 0 ? "" : s.slice(e); + mantissa = mantissa.replace(/0+$/, "").replace(/\.$/, ""); + return mantissa + exp; + }; + + const fixedForm = (v, prec) => + { + if (prec <= 100 && Math.abs(v) < 1e21) return v.toFixed(prec); + // toFixed gives exponent notation from 1e21 on; C prints every digit + let s = BigInt(Math.trunc(v)).toString(); + if (v < 0 && s[0] !== "-") s = "-" + s; + return prec > 0 ? s + "." + "0".repeat(prec) : s; + }; + + const hexFloat = (v, prec, upper) => + { + if (v === 0) return (Object.is(v, -0) ? "-" : "") + "0x0p+0"; + const sign = v < 0 ? "-" : ""; + v = Math.abs(v); + const buf = new DataView(new ArrayBuffer(8)); + buf.setFloat64(0, v); + const bits = buf.getBigUint64(0); + let exp = Number((bits >> 52n) & 0x7ffn); + let mant = bits & 0xfffffffffffffn; + let lead = 1n; + if (exp === 0) { lead = 0n; exp = -1022; } else exp -= 1023; + let hex = mant.toString(16).padStart(13, "0"); + if (prec === undefined) hex = hex.replace(/0+$/, ""); + else hex = hex.slice(0, prec).padEnd(prec, "0"); + const s = `${sign}0x${lead}${hex ? "." + hex : ""}p${exp >= 0 ? "+" : ""}${exp}`; + return upper ? s.toUpperCase().replace("0X", "0X") : s; + }; + + const formatFloat = (v, conv, prec, alt) => + { + const upper = conv === conv.toUpperCase(); + if (!isFinite(v)) + { + const s = isNaN(v) ? "nan" : (v < 0 ? "-inf" : "inf"); + return upper ? s.toUpperCase() : s; + } + + switch (conv.toLowerCase()) + { + case "f": return fixedForm(v, prec ?? 6); + case "e": return expForm(v, prec ?? 6, upper); + case "a": return hexFloat(v, prec, upper); + case "g": + { + let p = prec ?? 6; + if (p === 0) p = 1; + if (v === 0) return alt ? (0).toFixed(p - 1) : (Object.is(v, -0) ? "-0" : "0"); + const x = Number(v.toExponential(p - 1).split("e")[1]); + let s = (p > x && x >= -4) ? fixedForm(v, p - 1 - x) : expForm(v, p - 1, upper); + return alt ? s : stripZeros(s); + } + } + + return String(v); + }; + + // C's printf over a format and the wasm vararg buffer: each argument at its own alignment + const format = (fmt, va) => + { + va = N(va); + const view = dv(); + const ptrSize = is64 ? 8 : 4; + const take = (size, kind) => + { + va = Math.ceil(va / size) * size; + let v; + if (kind === "f") v = view.getFloat64(va, true); + else if (size === 8) v = kind === "u" ? view.getBigUint64(va, true) : view.getBigInt64(va, true); + else v = kind === "u" ? view.getUint32(va, true) : view.getInt32(va, true); + va += size; + return v; + }; + + let out = ""; + const re = /%([-+ #0]*)(\*|\d+)?(?:\.(\*|\d*))?(hh|h|ll|l|L|z|j|t|q)?([diuoxXcspfFeEgGaAn%])/g; + let last = 0; + let m; + while ((m = re.exec(fmt)) !== null) + { + out += fmt.slice(last, m.index); + last = re.lastIndex; + let [, flags, width, prec, len, conv] = m; + if (conv === "%") { out += "%"; continue; } + if (width === "*") width = take(4, "i"); + if (prec === "*") prec = take(4, "i"); + width = width === undefined ? 0 : Number(width); + if (width < 0) { flags += "-"; width = -width; } + prec = prec === undefined ? undefined : (prec === "" ? 0 : Number(prec)); + if (prec !== undefined && prec < 0) prec = undefined; + + const intSize = (len === "ll" || len === "q" || len === "j") ? 8 : (len === "l" || len === "z" || len === "t") ? ptrSize : 4; + let body; + let sign = ""; + switch (conv) + { + case "d": + case "i": + { + let v = BigInt(take(intSize, "i")); + if (len === "h") v = BigInt.asIntN(16, v); + if (len === "hh") v = BigInt.asIntN(8, v); + if (v < 0n) { sign = "-"; v = -v; } + else if (flags.includes("+")) sign = "+"; + else if (flags.includes(" ")) sign = " "; + body = v.toString(); + if (prec !== undefined) body = (prec === 0 && v === 0n) ? "" : body.padStart(prec, "0"); + break; + } + case "u": + case "o": + case "x": + case "X": + { + let v = BigInt(take(intSize, "u")); + v = BigInt.asUintN(intSize * 8, v); + if (len === "h") v = BigInt.asUintN(16, v); + if (len === "hh") v = BigInt.asUintN(8, v); + body = v.toString(conv === "u" ? 10 : conv === "o" ? 8 : 16); + if (conv === "X") body = body.toUpperCase(); + if (prec !== undefined) body = (prec === 0 && v === 0n) ? "" : body.padStart(prec, "0"); + if (flags.includes("#") && v !== 0n) + { + if (conv === "x") sign = "0x"; + else if (conv === "X") sign = "0X"; + else if (conv === "o" && body[0] !== "0") body = "0" + body; + } + break; + } + case "c": + body = String.fromCharCode(take(4, "i") & 0xff); + break; + case "s": + { + const p = Number(take(ptrSize, "u")); + body = p === 0 ? "(null)" : readStr(p); + if (prec !== undefined) body = body.slice(0, prec); + break; + } + case "p": + body = "0x" + BigInt(take(ptrSize, "u")).toString(16); + break; + case "n": + take(ptrSize, "u"); + body = ""; + break; + default: + { + let v = take(8, "f"); + body = formatFloat(v, conv, prec, flags.includes("#")); + if (body[0] === "-") { sign = "-"; body = body.slice(1); } + else if (flags.includes("+")) sign = "+"; + else if (flags.includes(" ")) sign = " "; + break; + } + } + + const length = sign.length + body.length; + if (length < width) + { + if (flags.includes("-")) body = body + " ".repeat(width - length); + else if (flags.includes("0") && conv !== "s" && conv !== "c" && !(prec !== undefined && "diuoxX".includes(conv)) && /^[0-9a-fA-F.]/.test(body)) + body = "0".repeat(width - length) + body; + else sign = " ".repeat(width - length) + sign; + } + + out += sign + body; + } + + return out + fmt.slice(last); + }; + + // ---- strtod / strtol ---- + + const strtod = (str, endPtr) => + { + const p = N(str); + const s = readLatin1(p); + const m = /^[ \t\n\v\f\r]*([+-]?(?:0[xX](?:[0-9a-fA-F]+\.?[0-9a-fA-F]*|\.[0-9a-fA-F]+)(?:[pP][+-]?\d+)?|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|inf(?:inity)?|nan(?:\([0-9A-Za-z_]*\))?))/i.exec(s); + let value = 0; + let used = 0; + if (m) + { + used = m[0].length; + let t = m[1]; + let sign = 1; + if (t[0] === "-" || t[0] === "+") { if (t[0] === "-") sign = -1; t = t.slice(1); } + if (/^inf/i.test(t)) value = Infinity; + else if (/^nan/i.test(t)) value = NaN; + else if (/^0x/i.test(t)) + { + const hm = /^0x([0-9a-f]*)(?:\.([0-9a-f]*))?(?:p([+-]?\d+))?$/i.exec(t); + const intPart = hm[1] || "0"; + const frac = hm[2] || ""; + value = parseInt(intPart, 16) + (frac ? parseInt(frac, 16) / Math.pow(16, frac.length) : 0); + value *= Math.pow(2, Number(hm[3] || 0)); + } + else value = Number(t); + value *= sign; + } + + if (N(endPtr) !== 0) + { + const view = dv(); + const e = p + used; + if (is64) view.setBigUint64(N(endPtr), BigInt(e), true); else view.setUint32(N(endPtr), e, true); + } + + return value; + }; + + const strtol = (str, endPtr, base) => + { + const p = N(str); + const s = readLatin1(p); + let i = 0; + while (/[ \t\n\v\f\r]/.test(s[i] ?? "")) i++; + let sign = 1n; + if (s[i] === "+" || s[i] === "-") { if (s[i] === "-") sign = -1n; i++; } + if ((base === 0 || base === 16) && s[i] === "0" && /[xX]/.test(s[i + 1] ?? "") && /[0-9a-fA-F]/.test(s[i + 2] ?? "")) { i += 2; base = 16; } + else if (base === 0 && s[i] === "0") base = 8; + else if (base === 0) base = 10; + const start = i; + let v = 0n; + for (; i < s.length; i++) + { + const d = parseInt(s[i], 36); + if (isNaN(d) || d >= base) break; + v = v * BigInt(base) + BigInt(d); + } + + const used = i === start ? 0 : i; + if (N(endPtr) !== 0) + { + const view = dv(); + if (is64) view.setBigUint64(N(endPtr), BigInt(p + used), true); else view.setUint32(N(endPtr), p + used, true); + } + + v *= sign; + if (v > 2147483647n) return 2147483647; + if (v < -2147483648n) return -2147483648; + return Number(v); + }; + + // strtoll / strtoull: strtol's parse, clamped to 64 bits; a BigInt + const strtolWide = (str, endPtr, base, signed) => + { + const p = N(str); + const s = readLatin1(p); + const m = /^[ \t\n\v\f\r]*([+-]?)(0[xX](?=[0-9a-fA-F]))?([0-9a-zA-Z]*)/.exec(s); + let radix = base; + let digits = m[3]; + if (m[2] && (radix === 0 || radix === 16)) radix = 16; + else if (radix === 0) radix = digits[0] === "0" ? 8 : 10; + let v = 0n, used = 0; + let i = 0; + for (; i < digits.length; i++) + { + const d = parseInt(digits[i], 36); + if (isNaN(d) || d >= radix) break; + v = v * BigInt(radix) + BigInt(d); + } + + if (i > 0) used = m[0].length - (digits.length - i); + if (m[1] === "-") v = -v; + if (N(endPtr) !== 0) + { + const view = dv(); + if (is64) view.setBigUint64(N(endPtr), BigInt(p + used), true); else view.setUint32(N(endPtr), p + used, true); + } + + if (signed) return v > 0x7fffffffffffffffn ? 0x7fffffffffffffffn : v < -0x8000000000000000n ? -0x8000000000000000n : v; + return BigInt.asUintN(64, v); + }; + + // a string that stays: strerror's result + const staticStrings = new Map(); + const staticString = (s) => + { + if (!staticStrings.has(s)) staticStrings.set(s, mallocBytesZ(utf8Encoder.encode(s))); + return staticStrings.get(s); + }; + + // ---- time ---- + + const readI64 = (p) => dv().getBigInt64(N(p), true); + const TM_FIELDS = 9; + const readTm = (p) => { p = N(p); const v = dv(); const t = []; for (let i = 0; i < TM_FIELDS; i++) t.push(v.getInt32(p + i * 4, true)); return t; }; + const writeTm = (p, t) => { p = N(p); const v = dv(); for (let i = 0; i < TM_FIELDS; i++) v.setInt32(p + i * 4, t[i], true); }; + // [sec, min, hour, mday, mon, year - 1900, wday, yday, isdst] + const tmOf = (date, utc) => + { + const g = (name) => date[(utc ? "getUTC" : "get") + name](); + const year = g("FullYear"); + const startOfYear = utc ? Date.UTC(year, 0, 1) : new Date(year, 0, 1).getTime(); + const startOfDay = utc ? Date.UTC(year, g("Month"), g("Date")) : new Date(year, g("Month"), g("Date")).getTime(); + const yday = Math.round((startOfDay - startOfYear) / 86400000); + return [g("Seconds"), g("Minutes"), g("Hours"), g("Date"), g("Month"), year - 1900, g("Day"), yday, utc ? 0 : (isDst(date) ? 1 : 0)]; + }; + const standardOffset = (year) => Math.max(new Date(year, 0, 1).getTimezoneOffset(), new Date(year, 6, 1).getTimezoneOffset()); + const isDst = (date) => date.getTimezoneOffset() < standardOffset(date.getFullYear()); + const dateOfTm = (t, utc) => + { + const [sec, min, hour, mday, mon, year] = t; + const d = new Date(0); + if (utc) { d.setUTCFullYear(year + 1900, mon, mday); d.setUTCHours(hour, min, sec, 0); } + else { d.setFullYear(year + 1900, mon, mday); d.setHours(hour, min, sec, 0); } + return d; + }; + const pad = (n, w = 2, c = "0") => String(n).padStart(w, c); + const asctime = (t) => `${WEEKDAYS[t[6]].slice(0, 3)} ${MONTHS[t[4]].slice(0, 3)} ${pad(t[3], 2, " ")} ${pad(t[2])}:${pad(t[1])}:${pad(t[0])} ${t[5] + 1900}\n`; + const zoneOf = (t) => + { + // a tm from gmtime has tm_isdst 0 and is read as UTC by glibc's %z/%Z only with tm_gmtoff; + // here every tm is local unless it is the UTC one gmtime_r gave + const d = dateOfTm(t, false); + const offset = -d.getTimezoneOffset(); + const sign = offset < 0 ? "-" : "+"; + const z = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}${pad(Math.abs(offset) % 60)}`; + let name = ""; + try { name = new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(d).find((x) => x.type === "timeZoneName")?.value ?? ""; } catch { } + return { z, name }; + }; + const strftime = (fmt, t) => + { + const [sec, min, hour, mday, mon, year, wday, yday] = t; + const Y = year + 1900; + return fmt.replace(/%([EO]?)([a-zA-Z%])/g, (all, mod, c) => + { + switch (c) + { + case "a": return WEEKDAYS[wday].slice(0, 3); + case "A": return WEEKDAYS[wday]; + case "b": case "h": return MONTHS[mon].slice(0, 3); + case "B": return MONTHS[mon]; + case "c": return asctime(t).slice(0, -1); + case "C": return pad(Math.floor(Y / 100)); + case "d": return pad(mday); + case "D": return `${pad(mon + 1)}/${pad(mday)}/${pad(Y % 100)}`; + case "e": return pad(mday, 2, " "); + case "F": return `${Y}-${pad(mon + 1)}-${pad(mday)}`; + case "H": return pad(hour); + case "I": return pad(hour % 12 === 0 ? 12 : hour % 12); + case "j": return pad(yday + 1, 3); + case "k": return pad(hour, 2, " "); + case "l": return pad(hour % 12 === 0 ? 12 : hour % 12, 2, " "); + case "m": return pad(mon + 1); + case "M": return pad(min); + case "n": return "\n"; + case "p": return hour < 12 ? "AM" : "PM"; + case "P": return hour < 12 ? "am" : "pm"; + case "r": return `${pad(hour % 12 === 0 ? 12 : hour % 12)}:${pad(min)}:${pad(sec)} ${hour < 12 ? "AM" : "PM"}`; + case "R": return `${pad(hour)}:${pad(min)}`; + case "S": return pad(sec); + case "t": return "\t"; + case "T": return `${pad(hour)}:${pad(min)}:${pad(sec)}`; + case "u": return String(wday === 0 ? 7 : wday); + case "w": return String(wday); + case "x": return `${pad(mon + 1)}/${pad(mday)}/${pad(Y % 100)}`; + case "X": return `${pad(hour)}:${pad(min)}:${pad(sec)}`; + case "y": return pad(Y % 100); + case "Y": return String(Y); + case "z": return zoneOf(t).z; + case "Z": return zoneOf(t).name; + case "%": return "%"; + default: return all; + } + }); + }; + + // ---- regex: std::regex's results, by handle ---- + + const matches = new Map(); + let nextMatch = 1; + const jsFlags = (flags) => "d" + (flags.includes("i") ? "i" : "") + (flags.includes("m") ? "m" : ""); + const regexError = (e) => { write(2, `Some other regex exception happened.\n${e.message}\n`); }; + // std::match_results::format, ECMAScript rules: $&, $n, $nn, $`, $', $$ + const formatMatch = (m, s, replacement) => + replacement.replace(/\$(\$|&|`|'|\d{1,2})/g, (all, x) => + { + if (x === "$") return "$"; + if (x === "&") return m[0]; + if (x === "`") return s.slice(0, m.index); + if (x === "'") return s.slice(m.index + m[0].length); + let n = Number(x); + if (x.length === 2 && n >= m.length) { n = Number(x[0]); return (n < m.length ? (m[n] ?? "") : all.slice(0, 2)) + x[1]; } + return n < m.length ? (m[n] ?? "") : all; + }); + const mallocBytesZ = (bytes) => { const p = malloc(bytes.length + 1); writeBytesZ(p, bytes, bytes.length + 1); return p; }; + + // ---- http: no network from a wasm program; every request fails ---- + + const responses = new Map(); + let nextResponse = 1; + + // ---- process and fs (Node) ---- + + let result = ""; + let resultBytes = new Uint8Array(0); + let entries = []; + const setResult = (bytes) => { resultBytes = typeof bytes === "string" ? utf8Encoder.encode(bytes) : bytes; return resultBytes.length; }; + const fsFail = (e, op, path, dest) => + { + const code = e?.code ?? "EIO"; + const text = (e?.message ?? String(e)).replace(/^[A-Z]+: /, "").replace(/, .*$/, ""); + setResult(`${code}: ${text}, ${op}${path !== undefined ? ` '${path}'` : ""}${dest !== undefined ? ` -> '${dest}'` : ""}`); + return -1; + }; + const needNode = (what) => { if (!node) throw new Error(`${what} needs the Node host`); }; + + let stdinLine = new Uint8Array(0); + const stdinBuffer = { bytes: new Uint8Array(0), pos: 0, eof: false }; + const stdinByte = () => + { + if (stdinBuffer.pos >= stdinBuffer.bytes.length) + { + if (stdinBuffer.eof || !node) return -1; + const chunk = new Uint8Array(65536); + let n = 0; + try { n = node.fs.readSync(0, chunk, 0, chunk.length, null); } catch (e) { if (e.code === "EAGAIN") return stdinByte(); if (e.code !== "EOF") n = 0; } + if (n <= 0) { stdinBuffer.eof = true; return -1; } + stdinBuffer.bytes = chunk.subarray(0, n); + stdinBuffer.pos = 0; + } + + return stdinBuffer.bytes[stdinBuffer.pos++]; + }; + + const env = { + // memory + malloc: (size) => P(malloc(size)), + calloc: (count, size) => P(calloc(count, size)), + realloc: (p, size) => P(realloc(p, size)), + free: (p) => free(p), + boundedCopy: (dest, destSize, src, count) => { const n = Math.min(N(destSize), N(count)); u8().copyWithin(N(dest), N(src), N(src) + n); }, + memset: (p, value, n) => { u8().fill(value & 0xff, N(p), N(p) + N(n)); return p; }, + memcpy: (d, s, n) => { u8().copyWithin(N(d), N(s), N(s) + N(n)); return d; }, + memmove: (d, s, n) => { u8().copyWithin(N(d), N(s), N(s) + N(n)); return d; }, + memcmp: (a, b, n) => { const m = u8(); a = N(a); b = N(b); for (let i = 0; i < N(n); i++) if (m[a + i] !== m[b + i]) return m[a + i] - m[b + i]; return 0; }, + + // strings + strlen: (p) => P(cstrEnd(N(p)) - N(p)), + strcmp: (a, b) => + { + const m = u8(); + a = N(a); b = N(b); + while (m[a] !== 0 && m[a] === m[b]) { a++; b++; } + return m[a] - m[b]; + }, + strncmp: (a, b, n) => + { + const m = u8(); + a = N(a); b = N(b); n = N(n); + for (let i = 0; i < n; i++, a++, b++) { if (m[a] !== m[b] || m[a] === 0) return m[a] - m[b]; } + return 0; + }, + strcoll: (a, b) => { const x = readStr(a), y = readStr(b); return x < y ? -1 : x > y ? 1 : 0; }, + strchr: (p, c) => + { + const m = u8(); + p = N(p); c &= 0xff; + for (; ; p++) { if (m[p] === c) return P(p); if (m[p] === 0) return P(0); } + }, + strstr: (h, n) => + { + const hs = readLatin1(h), ns = readLatin1(n); + const i = hs.indexOf(ns); + return P(i < 0 ? 0 : N(h) + i); + }, + strcpy: (d, s) => { const n = cstrEnd(N(s)) - N(s); u8().copyWithin(N(d), N(s), N(s) + n + 1); return d; }, + strcat: (d, s) => { const e = cstrEnd(N(d)); const n = cstrEnd(N(s)) - N(s); u8().copyWithin(e, N(s), N(s) + n + 1); return d; }, + tolower: (c) => (c >= 65 && c <= 90 ? c + 32 : c), + toupper: (c) => (c >= 97 && c <= 122 ? c - 32 : c), + isspace: (c) => ((c === 32 || (c >= 9 && c <= 13)) ? 1 : 0), + isdigit: (c) => ((c >= 48 && c <= 57) ? 1 : 0), + isalpha: (c) => (((c | 32) >= 97 && (c | 32) <= 122) ? 1 : 0), + atoi: (p) => strtol(p, P(0), 10), + atof: (p) => strtod(p, P(0)), + strtod, + strtol, + strtoll: (str, endPtr, base) => BigInt(strtolWide(str, endPtr, base, true)), + strtoull: (str, endPtr, base) => BigInt.asIntN(64, strtolWide(str, endPtr, base, false)), + strerror: (errnum) => P(staticString(`errno ${errnum}`)), + getenv: (name) => { const v = node?.process.env[readStr(name)]; return P(v === undefined ? 0 : staticString(v)); }, + qsort: (base, count, size, compare) => + { + base = N(base); count = N(count); size = N(size); + const cmp = instance.exports.__indirect_function_table.get(N(compare)); + const items = []; + for (let i = 0; i < count; i++) items.push(u8().slice(base + i * size, base + (i + 1) * size)); + // each pair is compared in two scratch blocks, as C's qsort hands out pointers into the array + const a = malloc(size), b = malloc(size); + items.sort((x, y) => { u8().set(x, a); u8().set(y, b); return cmp(P(a), P(b)); }); + free(a); free(b); + items.forEach((item, i) => u8().set(item, base + i * size)); + }, + snprintf: (out, n, fmt, va) => + { + const s = format(readStr(fmt), va); + const bytes = utf8Encoder.encode(s); + if (N(out) !== 0 && N(n) > 0) writeBytesZ(out, bytes, N(n)); + return bytes.length; + }, + sprintf: (out, fmt, va) => { const bytes = utf8Encoder.encode(format(readStr(fmt), va)); return writeBytesZ(out, bytes); }, + printf: (fmt, va) => { const s = format(readStr(fmt), va); write(1, s); return s.length; }, + setlocale: () => P(0), + + // output + puts: (p) => { write(1, readStr(p) + "\n"); return 1; }, + putchar: (c) => { write(1, String.fromCharCode(c)); return c; }, + fflush: () => 0, + stdio_write: (fd, s, count) => { if (count > 0) write(fd === 2 ? 2 : 1, utf8Decoder.decode(bytesOf(N(s), count))); }, + stdio_write_bytes: (fd, s, count) => { if (count > 0) write(fd === 2 ? 2 : 1, utf8Decoder.decode(bytesOf(N(s), count))); return Math.max(0, count); }, + stdio_read_line: () => + { + const bytes = []; + let c; + let read = false; + while ((c = stdinByte()) >= 0) { read = true; if (c === 10) break; bytes.push(c); } + if (!read) return -1; + if (bytes.length && bytes[bytes.length - 1] === 13) bytes.pop(); + stdinLine = Uint8Array.from(bytes); + return stdinLine.length; + }, + stdio_line_copy_to: (buffer, count) => { if (N(buffer) && count > 0) u8().set(stdinLine.subarray(0, count), N(buffer)); }, + stdio_read: (buffer, count) => + { + let n = 0; + const m = u8(); + for (; n < count; n++) { const c = stdinByte(); if (c < 0) break; m[N(buffer) + n] = c; } + return n; + }, + + // C++ exceptions: nothing catches them on wasm yet + __cxa_allocate_exception: (size) => P(malloc(size)), + __cxa_free_exception: (p) => free(p), + __cxa_throw: (object) => { throw new UncaughtThrow(N(object)); }, + __assert_fail: (msg, file, line, fn) => { throw new Error(`Assertion failed: ${readStr(msg)}, file ${readStr(file)}, line ${line}`); }, + abort: () => { throw new Error("abort()"); }, + exit: (code) => { throw new ExitSignal(code); }, + + // math + acos: Math.acos, acosh: Math.acosh, asin: Math.asin, asinh: Math.asinh, atan: Math.atan, atan2: Math.atan2, atanh: Math.atanh, + cbrt: Math.cbrt, cos: Math.cos, cosh: Math.cosh, exp: Math.exp, expm1: Math.expm1, log: Math.log, log10: Math.log10, + log1p: Math.log1p, log2: Math.log2, pow: Math.pow, sin: Math.sin, sinh: Math.sinh, tan: Math.tan, tanh: Math.tanh, + sqrt: Math.sqrt, floor: Math.floor, ceil: Math.ceil, trunc: Math.trunc, fabs: Math.abs, hypot: Math.hypot, + // C's round: halves away from zero (Math.round takes them up) + round: (x) => (x < 0 ? -Math.round(-x) : Math.round(x)), + fmod: (x, y) => x % y, + fmin: Math.min, fmax: Math.max, + abs: (x) => (x < 0 ? -x | 0 : x), + labs: (x) => (typeof x === "bigint" ? (x < 0n ? -x : x) : (x < 0 ? -x | 0 : x)), + llabs: (x) => (x < 0n ? -x : x), + rand: () => Math.floor(Math.random() * 2147483648), + srand: () => { }, + + // time + time: (p) => { const t = BigInt(Math.floor(Date.now() / 1000)); if (N(p) !== 0) dv().setBigInt64(N(p), t, true); return t; }, + tslang_wasm_now: () => Date.now(), + tslang_wasm_timezone: () => standardOffset(new Date().getFullYear()) * 60, + gmtime_r: (timePtr, tmPtr) => { writeTm(tmPtr, tmOf(new Date(Number(readI64(timePtr)) * 1000), true)); return tmPtr; }, + localtime_r: (timePtr, tmPtr) => { writeTm(tmPtr, tmOf(new Date(Number(readI64(timePtr)) * 1000), false)); return tmPtr; }, + timegm: (tmPtr) => { const d = dateOfTm(readTm(tmPtr), true); return isNaN(d.getTime()) ? -1n : BigInt(Math.floor(d.getTime() / 1000)); }, + mktime: (tmPtr) => { const d = dateOfTm(readTm(tmPtr), false); return isNaN(d.getTime()) ? -1n : BigInt(Math.floor(d.getTime() / 1000)); }, + ctime_r: (timePtr, buffer) => { writeStrZ(buffer, asctime(tmOf(new Date(Number(readI64(timePtr)) * 1000), false)), 26); return buffer; }, + asctime_r: (tmPtr, buffer) => { writeStrZ(buffer, asctime(readTm(tmPtr)), 26); return buffer; }, + strftime: (out, max, fmt, tmPtr) => + { + const bytes = utf8Encoder.encode(strftime(readStr(fmt), readTm(tmPtr))); + if (bytes.length + 1 > N(max)) { if (N(max) > 0) u8()[N(out)] = 0; return P(0); } + return P(writeBytesZ(out, bytes, N(max))); + }, + parse_date: (p) => { const t = Date.parse(readStr(p)); return isNaN(t) ? -1n : BigInt(t); }, + + // regex (regex.cpp) + regexp_test: (expr, flags, s) => + { + try + { + const m = new RegExp(readLatin1(expr), jsFlags(readStr(flags))).exec(readLatin1(s)); + return m ? m.index + m[0].length : -1; + } + catch (e) { regexError(e); return -1; } + }, + regexp_exec: (expr, flags, s, cm) => + { + try + { + const text = readLatin1(s); + const m = new RegExp(readLatin1(expr), jsFlags(readStr(flags))).exec(text); + if (!m) return P(0); + let handle = N(cm); + if (handle === 0) handle = nextMatch++; + matches.set(handle, { m, text }); + return P(handle); + } + catch (e) { regexError(e); return P(0); } + }, + regexp_match_results_size: (cm) => P(matches.get(N(cm)).m.length), + regexp_match_results_prefix_length: (cm) => P(matches.get(N(cm)).m.index), + regexp_match_results_suffix_position: (cm) => { const { m } = matches.get(N(cm)); return P(m.index + m[0].length); }, + regexp_match_results_sub_match_str_length: (cm, i) => P((matches.get(N(cm)).m[N(i)] ?? "").length), + regexp_match_results_sub_match_str_copy_to: (cm, i, buffer, count) => { const b = latin1Bytes(matches.get(N(cm)).m[N(i)] ?? ""); u8().set(b.subarray(0, N(count)), N(buffer)); }, + regexp_match_results_sub_match_position: (cm, i) => { const { m } = matches.get(N(cm)); const ix = m.indices[N(i)]; return P(ix ? ix[0] : m.index + m[0].length); }, + regexp_match_results_sub_match_length: (cm, i) => P((matches.get(N(cm)).m[N(i)] ?? "").length), + regexp_match_results_format: (cm, replacement) => { const { m, text } = matches.get(N(cm)); return P(mallocBytesZ(latin1Bytes(formatMatch(m, text, readLatin1(replacement))))); }, + regexp_replace: (expr, flags, s, replacement) => + { + try + { + const text = readLatin1(s); + const repl = readLatin1(replacement); + const re = new RegExp(readLatin1(expr), "g" + jsFlags(readStr(flags))); + const result = text.replace(re, (...a) => + { + const groupsAt = typeof a[a.length - 1] === "object" ? a.length - 3 : a.length - 2; + const m = a.slice(0, groupsAt); + m.index = a[groupsAt]; + return formatMatch(m, text, repl); + }); + return P(mallocBytesZ(latin1Bytes(result))); + } + catch (e) { regexError(e); return P(0); } + }, + regexp_free: (cm) => { matches.delete(N(cm)); }, + regexp_free_string: (p) => free(p), + + // http (http.cpp): fails with an error code, as a request that could not connect + http_request: () => { const h = nextResponse++; responses.set(h, {}); return P(h); }, + http_response_success: () => 0, + http_response_error_code: () => 12029, + http_response_status: () => 0, + http_response_headers_length: () => P(0), + http_response_headers_copy_to: () => { }, + http_response_body_length: () => P(0), + http_response_body_copy_to: () => { }, + http_response_free: (r) => { responses.delete(N(r)); }, + + // process and fs (os.cpp) + tslang_os_result_length: () => resultBytes.length, + tslang_os_result_copy_to: (buffer, count) => { if (N(buffer) && count > 0) u8().set(resultBytes.subarray(0, count), N(buffer)); }, + tslang_os_entry: (i) => (i < 0 || i >= entries.length ? -1 : setResult(entries[i])), + tslang_process_argc: () => args.length, + tslang_process_argv: (i) => (i < 0 || i >= args.length ? -1 : setResult(args[i])), + tslang_process_getenv: (name) => { const v = node?.process.env[readStr(name)]; return v === undefined ? -1 : setResult(v); }, + tslang_process_setenv: (name, value) => { if (node) node.process.env[readStr(name)] = readStr(value); return 0; }, + tslang_process_unsetenv: (name) => { if (node) delete node.process.env[readStr(name)]; return 0; }, + tslang_process_env_count: () => { entries = node ? Object.entries(node.process.env).map(([k, v]) => `${k}=${v}`) : []; return entries.length; }, + tslang_process_cwd: () => setResult(node ? node.process.cwd() : "/"), + tslang_process_chdir: (path) => { needNode("process.chdir"); try { node.process.chdir(readStr(path)); return 0; } catch (e) { return fsFail(e, "chdir", readStr(path)); } }, + tslang_process_pid: () => (node ? node.process.pid : 1), + tslang_process_platform: () => setResult(node ? node.process.platform : "browser"), + tslang_process_exit: (code) => { throw new ExitSignal(code); }, + tslang_fs_read_file: (path) => + { + needNode("fs"); + try { return setResult(new Uint8Array(node.fs.readFileSync(readStr(path)))); } + catch (e) { return fsFail(e, e.code === "EISDIR" ? "read" : "open", readStr(path)); } + }, + tslang_fs_write_file: (path, bytes, count, append) => + { + needNode("fs"); + try { (append ? node.fs.appendFileSync : node.fs.writeFileSync)(readStr(path), bytesOf(N(bytes), Math.max(0, count))); return 0; } + catch (e) { return fsFail(e, "open", readStr(path)); } + }, + tslang_fs_stat: (path, sizePtr, mtimePtr) => + { + needNode("fs"); + try + { + const st = node.fs.statSync(readStr(path)); + dv().setFloat64(N(sizePtr), st.size, true); + dv().setFloat64(N(mtimePtr), Math.floor(st.mtimeMs), true); + return st.isFile() ? 1 : st.isDirectory() ? 2 : 3; + } + catch (e) { return fsFail(e, "stat", readStr(path)); } + }, + tslang_fs_read_dir: (path) => + { + needNode("fs"); + try { entries = node.fs.readdirSync(readStr(path)).sort(); return entries.length; } + catch (e) { entries = []; return fsFail(e, "scandir", readStr(path)); } + }, + tslang_fs_mkdir: (path, recursive) => { needNode("fs"); try { node.fs.mkdirSync(readStr(path), { recursive: !!recursive }); return 0; } catch (e) { return fsFail(e, "mkdir", readStr(path)); } }, + tslang_fs_remove: (path, recursive, force) => { needNode("fs"); try { node.fs.rmSync(readStr(path), { recursive: !!recursive, force: !!force }); return 0; } catch (e) { return fsFail(e, "rm", readStr(path)); } }, + tslang_fs_rmdir: (path) => { needNode("fs"); try { node.fs.rmdirSync(readStr(path)); return 0; } catch (e) { return fsFail(e, "rmdir", readStr(path)); } }, + tslang_fs_rename: (from, to) => { needNode("fs"); try { node.fs.renameSync(readStr(from), readStr(to)); return 0; } catch (e) { return fsFail(e, "rename", readStr(from), readStr(to)); } }, + tslang_fs_copy_file: (from, to) => { needNode("fs"); try { node.fs.copyFileSync(readStr(from), readStr(to)); return 0; } catch (e) { return fsFail(e, "copyfile", readStr(from), readStr(to)); } }, + }; + + // an import nobody wrote: a clear error when the program calls it, not a failed instantiation + const imports = { + env: new Proxy(env, { + get: (target, name) => + name in target ? target[name] : (...a) => { throw new Error(`the wasm host has no import env.${String(name)}`); }, + has: () => true, + }), + }; + + const run = (inst) => + { + instance = inst; + memory = inst.exports.memory; + is64 = typeof inst.exports.__heap_base?.value === "bigint"; + heapTop = Math.ceil(Number(inst.exports.__heap_base.value) / ALIGN) * ALIGN; + + try + { + inst.exports.__wasm_call_ctors?.(); + let code; + const main = inst.exports.__main_void ?? inst.exports.__original_main; + if (main) + { + code = main(); + } + else if (inst.exports.main.length >= 2) + { + // main(argc, argv): C's char **, its strings in the module's memory + const ptrSize = is64 ? 8 : 4; + const argv = malloc((args.length + 1) * ptrSize); + args.forEach((arg, i) => + { + const p = mallocBytesZ(utf8Encoder.encode(arg)); + if (is64) dv().setBigUint64(argv + i * ptrSize, BigInt(p), true); else dv().setUint32(argv + i * ptrSize, p, true); + }); + if (is64) dv().setBigUint64(argv + args.length * ptrSize, 0n, true); else dv().setUint32(argv + args.length * ptrSize, 0, true); + code = inst.exports.main(args.length, P(argv)); + } + else + { + code = inst.exports.main(); + } + + return typeof code === "number" ? code : (typeof code === "bigint" ? Number(code) : 0); + } + catch (e) + { + if (e instanceof ExitSignal) return e.code; + throw e; + } + }; + + return { imports, run, env }; +} diff --git a/docs/how/wasm/test/index.html b/docs/how/wasm/test/index.html index fb0a53aed..8c4e745f8 100644 --- a/docs/how/wasm/test/index.html +++ b/docs/how/wasm/test/index.html @@ -1,162 +1,50 @@ - + + + tslang wasm tests + - - \ No newline at end of file + diff --git a/docs/how/wasm/test/run.mjs b/docs/how/wasm/test/run.mjs new file mode 100644 index 000000000..0eb0e43db --- /dev/null +++ b/docs/how/wasm/test/run.mjs @@ -0,0 +1,43 @@ +// Runs one tslang wasm program under Node: node run.mjs program.wasm [args...] +// +// The exit code is the program's: what main returned or exit() was given, 1 for a trap or an +// uncaught exception. A wasm64 program needs Node's memory64 support, on by default from Node 24 +// and behind --experimental-wasm-memory64 before it (run_tests.mjs passes it). +import fs from "node:fs"; +import process from "node:process"; +import { createHost, UncaughtThrow } from "./host.mjs"; + +const file = process.argv[2]; +if (!file) +{ + console.error("usage: node run.mjs program.wasm [args...]"); + process.exit(2); +} + +const host = createHost({ + args: process.argv.slice(2), + node: { fs, process }, + write: (fd, text) => fs.writeSync(fd, text), +}); + +let code; +try +{ + const { instance } = await WebAssembly.instantiate(fs.readFileSync(file), host.imports); + code = host.run(instance); +} +catch (e) +{ + if (e instanceof UncaughtThrow) + { + fs.writeSync(2, "Uncaught exception\n"); + } + else + { + fs.writeSync(2, `${e?.stack ?? e}\n`); + } + + code = 1; +} + +process.exitCode = code & 0xff; diff --git a/docs/how/wasm/test/run_tests.mjs b/docs/how/wasm/test/run_tests.mjs new file mode 100644 index 000000000..4e526b525 --- /dev/null +++ b/docs/how/wasm/test/run_tests.mjs @@ -0,0 +1,283 @@ +// Compiles tests for wasm and runs each under Node with the JavaScript host (host.mjs). +// +// node run_tests.mjs [options] [filter] +// +// --suite=tester,defaultlib which tests (default: both) +// tester the tester's tests (tslang/test/tester/tests), as +// test-runner builds them: --no-default-lib. A test passes +// when its program exits with 0 and prints "done.". +// defaultlib the default library's tests (TypeScriptCompilerDefaultLib/tests), +// linked with the wasm default library, as its tests.ps1 runs +// them: a test passes when it prints "ALL DONE", writes +// nothing to stderr and exits with 0. +// --triple=wasm32,wasm64 targets (default: both) +// --mm=none,rc,own memory models (default: all three; gc has no wasm build) +// --debug --di --opt_level=0 programs and the debug default library (default: --opt) +// --tslang= tslang (default: the release build tree, else tslang on PATH) +// --default-lib-repo= the default library's repository (default: ../TypeScriptCompilerDefaultLib +// next to this one); its __build holds the wasm default library +// -j tests at a time (default: the number of CPUs) +// --out= where the .wasm files and results.txt go (default: ./out) +// --timeout= per program run (default: 60) +// filter a regular expression the test's file name has to match +// +// The tester's tests are the single-file ones its CMakeLists.txt runs (`test-compile-*`): the others +// in the folder are multi-file tests, tests that expect a compile error, or not run at all. +// +// Skipped, and listed as such: tests that catch an exception (try/catch does not catch on wasm yet: +// a throw ends the program), async tests (no async runtime for wasm), threads (none on wasm), the +// default library's tests marked "// gc only", "// linux only" or (under own) "// not own", and the +// tests in wasm_skips.txt, each with its reason. +// +// Under -mm=own, a test that does not compile for wasm is compiled once more for the host +// (--emit=obj): if that fails too, the failure is not wasm's - what -mm=own cannot prove yet - and it +// is listed apart (NATIVE), not counted as a failure. ctest runs only a few tests under own natively; +// it runs the whole corpus under none and rc, so there a wasm compile error is always a failure. +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../../../.."); + +const options = { + suite: ["tester", "defaultlib"], triple: ["wasm32", "wasm64"], mm: ["none", "rc", "own"], debug: false, + jobs: os.cpus().length, out: path.join(here, "out"), timeout: 60, filter: null, +}; +for (const arg of process.argv.slice(2)) +{ + let m; + if ((m = /^--suite=(.+)$/.exec(arg))) options.suite = m[1].split(","); + else if ((m = /^--triple=(.+)$/.exec(arg))) options.triple = m[1].split(","); + else if ((m = /^--mm=(.+)$/.exec(arg))) options.mm = m[1].split(","); + else if (arg === "--debug") options.debug = true; + else if ((m = /^--tslang=(.+)$/.exec(arg))) options.tslang = m[1]; + else if ((m = /^--default-lib-repo=(.+)$/.exec(arg))) options.defaultLibRepo = path.resolve(m[1]); + else if ((m = /^-j(\d+)$/.exec(arg))) options.jobs = Number(m[1]); + else if ((m = /^--out=(.+)$/.exec(arg))) options.out = path.resolve(m[1]); + else if ((m = /^--timeout=(\d+)$/.exec(arg))) options.timeout = Number(m[1]); + else if (!arg.startsWith("-")) options.filter = new RegExp(arg); + else { console.error(`unknown option ${arg}`); process.exit(2); } +} + +if (options.mm.includes("gc")) +{ + console.error("-mm=gc has no wasm build (the garbage collector is not built for wasm): use none, rc or own"); + process.exit(2); +} + +const exe = process.platform === "win32" ? "tslang.exe" : "tslang"; +options.tslang ??= [ + path.join(repo, "__build/tslang/windows-msbuild-2026-release/bin", exe), + path.join(repo, "__build/tslang/ninja/release/bin", exe), +].find((p) => fs.existsSync(p)) ?? exe; +options.defaultLibRepo ??= path.resolve(repo, "../TypeScriptCompilerDefaultLib"); +const defaultLibPath = path.join(options.defaultLibRepo, "__build"); + +// wasm_skips.txt: one test a line, ` [,...] # `. Triples and models +// given limit it to those (wasm32,own: wasm32 under own); none given, every leg. +const skips = new Map(); +const skipsFile = path.join(here, "wasm_skips.txt"); +if (fs.existsSync(skipsFile)) +{ + for (const line of fs.readFileSync(skipsFile, "utf8").split(/\r?\n/)) + { + const m = /^\s*([^#\s]+)(?:\s+([\w,]+))?\s*(?:#\s*(.*))?$/.exec(line); + if (!m) continue; + const only = m[2] ? m[2].split(",") : []; + skips.set(m[1], { + triples: only.filter((x) => x.startsWith("wasm")), + models: only.filter((x) => !x.startsWith("wasm")), + reason: m[3] ?? "listed in wasm_skips.txt", + }); + } +} + +// the single-file tests ctest runs: `tslang_add_test(NAME test-compile-... COMMAND test-runner [options] "")` +const testerFiles = () => +{ + const names = new Set(); + const cmake = fs.readFileSync(path.join(repo, "tslang/test/tester/CMakeLists.txt"), "utf8"); + const re = /tslang_add_test\(NAME test-compile-\S+ COMMAND test-runner((?: -[\w=-]+)*) ((?:"[^"]+"\s*)+)\)/g; + let m; + while ((m = re.exec(cmake)) !== null) + { + const testFiles = m[2].match(/"[^"]+"/g); + if (testFiles.length !== 1 || / -shared\b/.test(m[1])) continue; + names.add(testFiles[0].slice(1, -1).replace(/^.*\//, "")); + } + + // a name built in CMake (`${arc_test}.ts`) is not a file + return [...names].filter((name) => fs.existsSync(path.join(repo, "tslang/test/tester/tests", name))); +}; + +const suites = { + tester: { + dir: path.join(repo, "tslang/test/tester/tests"), + files: testerFiles, + compileArgs: ["--no-default-lib"], + passed: (run) => run.code === 0 && (run.stdout + run.stderr).includes("done.") ? null + : run.code !== 0 ? `exit code ${run.code}` : "no 'done.' msg.", + }, + defaultlib: { + dir: path.join(options.defaultLibRepo, "tests"), + files: () => fs.readdirSync(path.join(options.defaultLibRepo, "tests")).filter((f) => f.endsWith(".ts")), + compileArgs: ["--nowarn"], + cwd: options.defaultLibRepo, + marker: (source, leg) => + { + const first = source.split(/\r?\n/, 1)[0]; + if (first.startsWith("// gc only")) return "marked '// gc only'"; + if (first.startsWith("// linux only")) return "marked '// linux only'"; + if (first.startsWith("// not own") && leg.mm === "own") return "marked '// not own'"; + return null; + }, + passed: (run) => run.code !== 0 ? `exit code ${run.code}` + : !run.stdout.includes("ALL DONE") ? "no 'ALL DONE' marker" + : run.stderr.trim() ? "wrote to stderr" : null, + }, +}; + +const skipReason = (suite, file, source, leg) => +{ + const skip = skips.get(file); + if (skip && (!skip.triples.length || skip.triples.includes(leg.triple)) && (!skip.models.length || skip.models.includes(leg.mm))) + { + return skip.reason; + } + + const marked = suite.marker?.(source, leg); + if (marked) return marked; + if (/\bcatch\s*[({]/.test(source)) return "catches an exception: try/catch does not catch on wasm yet"; + if (/\basync\b|\bawait\b|\bPromise\b/.test(source)) return "async: the async runtime (TypeScriptAsyncRuntime) has no wasm build"; + if (/\bThread\.spawn\b|\bnew Worker\b|\bAtomics\.wait\b/.test(source)) return "threads: none on wasm"; + return null; +}; + +const runProcess = (command, args, { env, timeout, cwd, input } = {}) => new Promise((resolve) => +{ + const child = spawn(command, args, { env: { ...process.env, ...env }, cwd, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); + let stdout = "", stderr = "", timedOut = false; + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + if (input !== undefined) { child.stdin.on("error", () => { }); child.stdin.end(input); } + const timer = timeout ? setTimeout(() => { timedOut = true; child.kill(); }, timeout * 1000) : null; + child.on("error", (e) => { stderr += String(e); }); + child.on("close", (code) => { if (timer) clearTimeout(timer); resolve({ code, stdout, stderr, timedOut }); }); +}); + +// the environment the compiler looks the default library up in, and nothing of the user's own lib paths +const compileEnv = { DEFAULT_LIB_PATH: defaultLibPath, GC_LIB_PATH: "", LLVM_LIB_PATH: "", TSLANG_LIB_PATH: "" }; +const buildArgs = options.debug ? ["--di", "--opt_level=0"] : ["--opt"]; + +const legs = []; +for (const suiteName of options.suite) +{ + if (!suites[suiteName]) { console.error(`unknown suite ${suiteName}: tester or defaultlib`); process.exit(2); } + for (const triple of options.triple) + for (const mm of options.mm) + legs.push({ suiteName, suite: suites[suiteName], triple, mm, name: `${suiteName}-${triple}-${mm}${options.debug ? "-debug" : ""}` }); +} + +const jobs = []; +const results = []; +for (const leg of legs) +{ + const outDir = path.join(options.out, leg.name); + fs.mkdirSync(outDir, { recursive: true }); + const files = leg.suite.files().filter((f) => !options.filter || options.filter.test(f)).sort(); + for (const file of files) + { + const source = fs.readFileSync(path.join(leg.suite.dir, file), "utf8"); + const reason = skipReason(leg.suite, file, source, leg); + if (reason) { results.push({ leg: leg.name, file, status: "SKIP", detail: reason }); continue; } + jobs.push({ leg, file, outDir }); + } +} + +// wasm64: LLVM gives a wasm64 module 64-bit tables (table64), which V8 runs from Node 24 on; Node 22 +// has memory64 behind a flag, but not the tables. Before 24 a wasm64 test is compiled, not run. +const nodeMajor = Number(process.versions.node.split(".")[0]); +const nodeFlags = nodeMajor < 24 ? ["--experimental-wasm-memory64"] : []; +const canRun = (leg) => leg.triple !== "wasm64" || nodeMajor >= 24; +const runner = path.join(here, "run.mjs"); +const lastLines = (text, n) => text.trim().split(/\r?\n/).slice(-n).join("\n"); + +const runOne = async ({ leg, file, outDir }) => +{ + const source = path.join(leg.suite.dir, file); + const wasm = path.join(outDir, file.replace(/\.ts$/, ".wasm")); + fs.rmSync(wasm, { force: true }); + const compiled = await runProcess(options.tslang, ["--emit=exe", `-mm=${leg.mm}`, `-mtriple=${leg.triple}-unknown-unknown`, + ...buildArgs, ...leg.suite.compileArgs, `-o=${wasm}`, source], { env: compileEnv, timeout: 300 }); + if (compiled.code !== 0 || !fs.existsSync(wasm)) + { + let detail = (compiled.stderr + compiled.stdout).trim().split(/\r?\n/).slice(0, 6).join("\n"); + if (leg.mm !== "own") return { leg: leg.name, file, status: "COMPILE", detail }; + + const obj = path.join(outDir, file.replace(/\.ts$/, ".native.o")); + const native = await runProcess(options.tslang, ["--emit=obj", `-mm=${leg.mm}`, ...buildArgs, ...leg.suite.compileArgs, + `-o=${obj}`, source], { env: compileEnv, timeout: 300 }); + fs.rmSync(obj, { force: true }); + if (native.code === 0) return { leg: leg.name, file, status: "COMPILE", detail }; + return { leg: leg.name, file, status: "NATIVE", detail: "native: " + (native.stderr + native.stdout).trim().split(/\r?\n/).slice(0, 4).join("\n") }; + } + + if (!canRun(leg)) + { + return { leg: leg.name, file, status: "BUILT", detail: `compiled; not run: ${leg.triple} needs Node 24 or later (this is ${process.version})` }; + } + + const stdinFile = source.replace(/\.ts$/, ".stdin"); + const input = fs.existsSync(stdinFile) ? fs.readFileSync(stdinFile) : undefined; + const run = await runProcess(process.execPath, ["--no-warnings", ...nodeFlags, runner, wasm], + { timeout: options.timeout, cwd: leg.suite.cwd, input }); + if (run.timedOut) return { leg: leg.name, file, status: "TIMEOUT", detail: lastLines(run.stdout + run.stderr, 6) }; + const failure = leg.suite.passed(run); + if (failure) return { leg: leg.name, file, status: "FAIL", detail: `${failure}\n` + lastLines(run.stdout + run.stderr, 8) }; + return { leg: leg.name, file, status: "PASS" }; +}; + +let next = 0, finished = 0; +const total = jobs.length; +const worker = async () => +{ + while (next < jobs.length) + { + const job = jobs[next++]; + const result = await runOne(job); + results.push(result); + finished++; + if (["FAIL", "COMPILE", "TIMEOUT"].includes(result.status)) console.log(`[${finished}/${total}] ${result.status} ${result.leg} ${result.file}`); + else if (finished % 100 === 0) console.log(`[${finished}/${total}] ...`); + } +}; + +console.log(`tslang: ${options.tslang}\ndefault lib: ${defaultLibPath}\nlegs: ${legs.map((l) => l.name).join(", ")}; ${total} runs, ${results.length} skips`); +await Promise.all(Array.from({ length: Math.max(1, options.jobs) }, worker)); + +results.sort((a, b) => a.leg.localeCompare(b.leg) || a.file.localeCompare(b.file)); +const lines = []; +for (const leg of legs) +{ + const mine = results.filter((r) => r.leg === leg.name); + const count = (s) => mine.filter((r) => r.status === s).length; + lines.push(`== ${leg.name}: ${count("PASS")} passed, ${count("FAIL") + count("COMPILE") + count("TIMEOUT")} failed (${count("COMPILE")} to compile, ${count("TIMEOUT")} timed out), ${count("SKIP")} skipped` + + (count("NATIVE") ? `, ${count("NATIVE")} do not compile natively under own either` : "") + + (count("BUILT") ? `, ${count("BUILT")} compiled but not run` : "")); +} + +const report = [...lines, ""]; +for (const r of results.filter((r) => r.status !== "PASS")) +{ + report.push(`${r.status} ${r.leg} ${r.file}${r.detail ? "\n " + r.detail.replace(/\n/g, "\n ") : ""}`); +} + +fs.mkdirSync(options.out, { recursive: true }); +fs.writeFileSync(path.join(options.out, "results.txt"), report.join("\n") + "\n"); +console.log(lines.join("\n")); +console.log(`details: ${path.join(options.out, "results.txt")}`); +process.exitCode = results.some((r) => ["FAIL", "COMPILE", "TIMEOUT"].includes(r.status)) ? 1 : 0; diff --git a/docs/how/wasm/test/wasm_skips.txt b/docs/how/wasm/test/wasm_skips.txt new file mode 100644 index 000000000..2cf74819a --- /dev/null +++ b/docs/how/wasm/test/wasm_skips.txt @@ -0,0 +1,28 @@ +# Tests run_tests.mjs does not run for wasm: ` [,...] # `, every leg +# when none is given. Tests that catch an exception and async tests are skipped without being +# listed here. + +# cannot work on wasm +internals.ts # inline_asm with x86 instructions +00any_to_opaque_narrow.ts wasm32 # stores a u64 in an Opaque: a pointer is 4 bytes on wasm32 +00tslang_issue_29_pthread.ts # starts threads through pthread_create: no threads on wasm +00tslang_issue_29_thread.ts # starts threads through _beginthreadex: no threads on wasm +00cppvtable_hidden_return.ts # a @cpp class returning a struct needs the MSVC or a 64-bit Itanium ABI (excluded on i686 too) +fetch.ts # (default library) fetch(): the host has no network, so the request throws + +# compiler gaps on wasm, each to be fixed and taken off this list +00function_overloads.ts # an override with another arity fills a vtable slot: wasm's call_indirect checks the signature (trap) +00object_func2.ts # a function called through a pointer of another signature: wasm's call_indirect traps +callWithSpread.ts # a function called through a pointer of another signature: wasm's call_indirect traps +00class_accessor_implements_field.ts wasm32 # interface accessor cell tag (address + 0x80000000) folded into an i32.const: wasm-ld "LEB is outside Varint32 range" +00interface_field_getter_owned.ts wasm32 # interface accessor cell tag (address + 0x80000000) folded into an i32.const: wasm-ld "LEB is outside Varint32 range" +00class_accessor_increment.ts wasm32 # the same accessor cell tag (--di --opt_level=0 programs) +00class_redeclared_field.ts wasm32 # the same accessor cell tag (--di --opt_level=0 programs) +00generator3.ts wasm64 # wasm-ld: invalid data relocation for an object literal's generator method `next` +01iterator.ts wasm64 # wasm-ld: invalid data relocation for an object literal's generator method `next` +00owned_generator_locals.ts wasm64 # wasm-ld: invalid data relocation for a generator's `next` (under none) +path.ts # a field of Readonly read through Maybe<...> is read wrong: the loop never ends + +# fails natively under the same model too (not run natively under it by ctest) +01tuple.ts own # frees a block it does not own (native own: the program does not finish either) +00global_interface_initializer.ts own # asserts "a field read" (native own: the same assert) diff --git a/tslang/lib/TypeScript/ObjDumper.cpp b/tslang/lib/TypeScript/ObjDumper.cpp index 00adffe88..689d27d5b 100644 --- a/tslang/lib/TypeScript/ObjDumper.cpp +++ b/tslang/lib/TypeScript/ObjDumper.cpp @@ -4,6 +4,7 @@ #include "llvm/Object/Binary.h" #include "llvm/Object/COFFImportFile.h" #include "llvm/Object/ObjectFile.h" +#include "llvm/Object/Wasm.h" #include "llvm/Support/Endian.h" #include "llvm/Support/Errc.h" #include "llvm/Support/Error.h" @@ -121,6 +122,36 @@ static void readCStringSymbolsOf(const ObjectFile &objFile, Symbols symbols, Str continue; } + // A wasm data symbol is an offset into its data segment. Its section is the whole DATA + // section, whose contents begin with the segments' headers, so the offset below would read + // the wrong bytes: an own program linked with a wasm default library got none of its + // borrow facts. + if (auto *wasmObj = dyn_cast(&objFile)) + { + const auto &wasmSymbol = wasmObj->getWasmSymbol(symbol); + if (!wasmSymbol.isTypeData() || wasmSymbol.isUndefined()) + { + continue; + } + + auto segmentIndex = wasmSymbol.Info.DataRef.Segment; + if (segmentIndex >= wasmObj->dataSegments().size()) + { + continue; + } + + auto segment = wasmObj->dataSegments()[segmentIndex].Data.Content; + auto offset = wasmSymbol.Info.DataRef.Offset; + if (offset >= segment.size()) + { + continue; + } + + StringRef text(reinterpret_cast(segment.data()) + offset, segment.size() - offset); + result.push_back({name->str(), text.substr(0, text.find(char(0))).str()}); + continue; + } + auto section = symbol.getSection(); if (!section) { diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index d8543bd33..434afe9a6 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -529,6 +529,18 @@ int main(int argc, char **argv) auto compileOptions = prepareOptions(); + // The collector has no wasm build, so a wasm program under gc would import GC_malloc and the + // rest from a host that has none - and the link would first fail on a default library that is + // never built for it, an error that names a folder rather than the cause. + if (compileOptions.isWasm && compileOptions.needsGCRuntime() + && (emitAction == Action::BuildExe || emitAction == Action::BuildDll)) + { + llvm::WithColor::error(llvm::errs(), "tslang") + << "-mm=gc (the default) is not available for " << compileOptions.moduleTargetTriple + << ": the garbage collector has no wasm build. Use -mm=rc, -mm=own or -mm=none.\n"; + return 1; + } + if (!prepareDefaultLib(compileOptions)) { return 0; From d11707256379ef4d1d90675d33f26593f1e3aaba Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 11 Nov 2026 18:39:39 +0000 Subject: [PATCH 2/2] An override of another arity gets its own vtable slot; the inherited slots call it through an adapter A method overriding one with other parameters (get(scale?) for an abstract get(), foo() for foo(x, y, ...z)) was stored in the base method's slot as it was. Native code tolerates the mismatch, but wasm's call_indirect checks the signature and traps. Now, as in C++, the override gets a slot of its own type, and each inherited slot holds a thunk of that slot's type: it passes undefined for a missing optional parameter, an empty array for a missing rest parameter, and drops extra arguments. 00function_overloads and callWithSpread now run on wasm32 and wasm64. Co-Authored-By: Claude Opus 5.5 --- docs/how/wasm/test/wasm_skips.txt | 2 - .../TypeScript/MLIRLogic/MLIRGenStore.h | 85 ++++++++++++- tslang/lib/TypeScript/MLIRGenClasses.cpp | 113 ++++++++++++++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 4 + tslang/test/tester/CMakeLists.txt | 3 + .../tester/tests/00class_override_arity.ts | 85 +++++++++++++ 6 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tslang/test/tester/tests/00class_override_arity.ts diff --git a/docs/how/wasm/test/wasm_skips.txt b/docs/how/wasm/test/wasm_skips.txt index 2cf74819a..a2468fa71 100644 --- a/docs/how/wasm/test/wasm_skips.txt +++ b/docs/how/wasm/test/wasm_skips.txt @@ -11,9 +11,7 @@ internals.ts # inline_asm with x86 instructions fetch.ts # (default library) fetch(): the host has no network, so the request throws # compiler gaps on wasm, each to be fixed and taken off this list -00function_overloads.ts # an override with another arity fills a vtable slot: wasm's call_indirect checks the signature (trap) 00object_func2.ts # a function called through a pointer of another signature: wasm's call_indirect traps -callWithSpread.ts # a function called through a pointer of another signature: wasm's call_indirect traps 00class_accessor_implements_field.ts wasm32 # interface accessor cell tag (address + 0x80000000) folded into an i32.const: wasm-ld "LEB is outside Varint32 range" 00interface_field_getter_owned.ts wasm32 # interface accessor cell tag (address + 0x80000000) folded into an i32.const: wasm-ld "LEB is outside Varint32 range" 00class_accessor_increment.ts wasm32 # the same accessor cell tag (--di --opt_level=0 programs) diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h index 9cbd4d0a9..bcd5732c9 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h @@ -207,6 +207,14 @@ struct VirtualMethodOrInterfaceVTableInfo StaticFieldInfo staticFieldInfo; bool isStaticField; bool isInterfaceVTable; + // A method's slot: the type of the method that made it, which every call through it is made with + // (a call through the class that declared it). An override of another arity has a slot of its own, + // and this one then holds a thunk of this type calling it (adaptTo): called with the arguments the + // slot's type says, the override would read them where it does not expect them, and wasm's + // call_indirect traps on the signature. + mlir_ts::FunctionType slotType; + // the override's own type when the slot holds a thunk calling it (methodInfo.funcName); null otherwise + mlir_ts::FunctionType adaptTo; }; struct AccessorInfo @@ -981,11 +989,21 @@ struct ClassInfo // find_if searched both kinds through methodInfo.name). llvm::StringMap methodSlots; llvm::StringMap interfaceSlots; - getVirtualTable(vtable, methodSlots, interfaceSlots); + llvm::StringMap> methodSlotsOfArity; + getVirtualTable(vtable, methodSlots, interfaceSlots, methodSlotsOfArity); + } + + // the same parameters and results as far as a call through a slot goes: `this` aside (a class of + // its own in each), as many of each + static bool sameSlotArity(mlir_ts::FunctionType slotType, mlir_ts::FunctionType funcType) + { + return slotType.getNumInputs() == funcType.getNumInputs() && slotType.getNumResults() == funcType.getNumResults() + && slotType.isVarArg() == funcType.isVarArg(); } void getVirtualTable(llvm::SmallVector &vtable, - llvm::StringMap &methodSlots, llvm::StringMap &interfaceSlots) + llvm::StringMap &methodSlots, llvm::StringMap &interfaceSlots, + llvm::StringMap> &methodSlotsOfArity) { // in static class I don't want to have virtual table if (isStatic) @@ -995,7 +1013,7 @@ struct ClassInfo for (auto &base : baseClasses) { - base->getVirtualTable(vtable, methodSlots, interfaceSlots); + base->getVirtualTable(vtable, methodSlots, interfaceSlots, methodSlotsOfArity); } // `.instanceOf` is slot 0 of every class vtable: an object reached with no static type (an @@ -1053,7 +1071,57 @@ struct ClassInfo #endif auto it = methodSlots.find(method.name); - if (it != methodSlots.end()) + if (it != methodSlots.end() && !isCppVTable && method.funcType && methodSlotsOfArity.contains(method.name)) + { + // an override: every slot of the name calls it, each through its own type. One of the + // same arity takes it as it is; one of another (`get()` overridden by `get(scale?)`, + // `foo(x, y, ...z)` by `foo()`) a thunk; and with none of its arity it has a slot of + // its own, which calls made through its class use. + auto &slots = methodSlotsOfArity[method.name]; + auto ownSlot = -1; + for (auto index : slots) + { + auto &record = vtable[index]; + auto slotType = record.slotType ? record.slotType : record.methodInfo.funcType; + record.methodInfo.funcName = method.funcName; + record.methodInfo.isAbstract = method.isAbstract; + if (!slotType || sameSlotArity(slotType, method.funcType)) + { + record.methodInfo.funcType = method.funcType; + record.adaptTo = mlir_ts::FunctionType(); + ownSlot = index; + } + else + { + // the thunk: the override's `this`, the slot's parameters and the override's results + llvm::SmallVector inputs; + inputs.push_back(method.funcType.getNumInputs() > 0 ? method.funcType.getInput(0) : slotType.getInput(0)); + inputs.append(slotType.getInputs().begin() + 1, slotType.getInputs().end()); + record.methodInfo.funcType = mlir_ts::FunctionType::get( + method.funcType.getContext(), inputs, method.funcType.getResults(), slotType.isVarArg()); + record.adaptTo = method.funcType; + } + } + + method.isVirtual = true; + if (ownSlot >= 0) + { + method.virtualIndex = ownSlot; + it->second = ownSlot; + } + else + { + method.virtualIndex = vtable.size(); + it->second = vtable.size(); + slots.push_back(vtable.size()); + VirtualMethodOrInterfaceVTableInfo record{method, false}; + // a field of its own name in the vtable's tuple, beside the slot of the other arity + record.methodInfo.name = method.name + ".args" + std::to_string(method.funcType.getNumInputs()); + record.slotType = method.funcType; + vtable.push_back(record); + } + } + else if (it != methodSlots.end()) { // found method - override the inherited slot (name and type together: // the overriding function's type must win over the inherited one) @@ -1128,7 +1196,14 @@ struct ClassInfo { method.virtualIndex = vtable.size(); methodSlots[method.name] = vtable.size(); - vtable.push_back({method, false}); + if (!isCppVTable && method.funcType) + { + methodSlotsOfArity[method.name].push_back(vtable.size()); + } + + VirtualMethodOrInterfaceVTableInfo record{method, false}; + record.slotType = method.funcType; + vtable.push_back(record); } } diff --git a/tslang/lib/TypeScript/MLIRGenClasses.cpp b/tslang/lib/TypeScript/MLIRGenClasses.cpp index 4ae764ee4..a9a724081 100644 --- a/tslang/lib/TypeScript/MLIRGenClasses.cpp +++ b/tslang/lib/TypeScript/MLIRGenClasses.cpp @@ -3171,6 +3171,109 @@ genContext); mlir::FlatSymbolRefAttr::get(builder.getContext(), thunkName)); } + // A slot of a class's vtable whose type has another arity than the override it calls (see + // VirtualMethodOrInterfaceVTableInfo::slotType): a function of the slot's type that calls the override + // with the arguments it takes - the slot's, as many as the override has, then for a parameter the slot + // does not have `undefined` (an optional one) or an empty array (the rest). Called with the slot's + // arguments, the override itself would read them where it does not expect them; natively it got away + // with that, wasm's call_indirect checks the signature and traps. + mlir::Value MLIRGenImpl::virtualSlotAdapterThunk(mlir::Location location, mlir::StringRef calleeName, + mlir_ts::FunctionType calleeType, mlir_ts::FunctionType thunkType, + const GenContext &genContext) + { + auto thunkName = (calleeName + ".slot" + std::to_string(thunkType.getNumInputs())).str(); + if (!theModule.lookupSymbol(thunkName)) + { + mlir::OpBuilder::InsertionGuard guard(builder); + + // compiler glue, as virtualDispatchThunk's: with --di a subprogram of its own at the file's + // place, never the scope of the function `location` is in + mlir::Location noLocation = mlir::UnknownLoc::get(builder.getContext()); + auto funcOp = mlir_ts::FuncOp::create(noLocation, thunkName, thunkType); + funcOp.setPrivate(); + funcOp->setAttr("internal_linkage", builder.getUnitAttr()); + + DITableScopeT debugFuncScope(debugScope); + auto fileLoc = location->findInstanceOf(); + if (!fileLoc) + { + fileLoc = mlir::FileLineColLoc::get(builder.getStringAttr(mainSourceFileName), 1, 1); + } + + if (compileOptions.generateDebugInfo && fileLoc) + { + MLIRDebugInfoHelper mdi(builder, debugScope); + mlir::Location plainLocation = fileLoc; + funcOp->setLoc(mdi.getSubprogram(plainLocation, thunkName, thunkName, plainLocation)); + noLocation = locFuseWithScope(plainLocation); + } + + auto &entryBlock = *funcOp.addEntryBlock(); + builder.setInsertionPointToStart(&entryBlock); + + auto results = calleeType.getResults(); + auto hasResult = !results.empty() && !isa(results.front()); + auto entryOp = builder.create( + noLocation, hasResult ? mlir::Type(mlir_ts::RefType::get(results.front())) : mlir::Type()); + + SmallVector operands; + for (auto [index, paramType] : llvm::enumerate(calleeType.getInputs())) + { + if (index < entryBlock.getNumArguments()) + { + mlir::Value argument = entryBlock.getArgument(index); + if (argument.getType() != paramType) + { + auto castResult = cast(noLocation, paramType, argument, genContext); + if (castResult.failed_or_no_value()) + { + funcOp.erase(); + return mlir::Value(); + } + + argument = V(castResult); + } + + operands.push_back(argument); + } + else if (isa(paramType)) + { + operands.push_back(builder.create(noLocation, paramType)); + } + else if (calleeType.isVarArg() && index + 1 == calleeType.getNumInputs() && isa(paramType)) + { + operands.push_back(builder.create(noLocation, paramType, mlir::ValueRange{})); + } + else + { + operands.push_back(builder.create(noLocation, paramType)); + } + } + + auto callee = builder.create(noLocation, calleeType, + mlir::FlatSymbolRefAttr::get(builder.getContext(), calleeName)); + auto callOp = builder.create(noLocation, callee, operands); + if (hasResult) + { + // handed on as `return this.m(x)` hands it: owned, and taken over by the return + if (mth.ownsHeapMemory(noLocation, results.front())) + { + callOp->setAttr(OWNED_RESULT_ATTR_NAME, builder.getUnitAttr()); + callOp->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, builder.getUnitAttr()); + } + + builder.create(noLocation, callOp.getResult(0), entryOp.getReference()); + } + + builder.create(noLocation, hasResult ? entryOp.getReference() : mlir::Value()); + + theModule.push_back(funcOp); + } + + return builder.create(location, thunkType, + mlir::FlatSymbolRefAttr::get(builder.getContext(), thunkName)); + } + std::pair MLIRGenImpl::findErasedGenericMethod(mlir::Location location, ClassInfo::TypePtr classInfo, mlir::StringRef name, @@ -4299,6 +4402,16 @@ genContext); methodOrFieldNameRef = V(castResult); } + else if (!vtRecord.isStaticField && vtRecord.adaptTo) + { + // an override of another arity than the slot: a thunk of the slot's type + methodOrFieldNameRef = virtualSlotAdapterThunk( + location, symbolName, vtRecord.adaptTo, vtRecord.methodInfo.funcType, genContext); + if (!methodOrFieldNameRef) + { + return TypeValueInitType{mlir::Type(), mlir::Value(), TypeProvided::No}; + } + } else { methodOrFieldNameRef = builder.create( diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index c6ea806de..188cae34a 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -14022,6 +14022,10 @@ class MLIRGenImpl mlir::Value virtualDispatchThunk(mlir::Location location, ClassInfo::TypePtr newClassPtr, MethodInfo &methodInfo, mlir::StringRef thunkName, const GenContext &genContext); + mlir::Value virtualSlotAdapterThunk(mlir::Location location, mlir::StringRef calleeName, + mlir_ts::FunctionType calleeType, mlir_ts::FunctionType thunkType, + const GenContext &genContext); + ValueOrLogicalResult mlirGenCreateInterfaceVTableForObject(mlir::Location location, mlir::Value in, mlir_ts::ObjectType objectType, InterfaceInfo::TypePtr newInterfacePtr, const GenContext &genContext, mlir::Value vtableStorage = mlir::Value()); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7fb8072be..c5c6ab9a4 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -297,6 +297,7 @@ tslang_add_test(NAME test-compile-00-funcs-capture COMMAND test-runner "${PROJEC tslang_add_test(NAME test-compile-00-funcs-vararg COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00funcs_vararg.ts") tslang_add_test(NAME test-compile-00-declare-function-twice COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00declare_function_twice.ts") tslang_add_test(NAME test-compile-00-function-overloads COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00function_overloads.ts") +tslang_add_test(NAME test-compile-00-class-override-arity COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_override_arity.ts") tslang_add_test(NAME test-compile-00-gc-allocations-distinct COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00gc_allocations_distinct.ts") tslang_add_test(NAME test-compile-00-assert-message COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00assert_message.ts") tslang_add_test(NAME test-compile-01-funcs-vararg COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/01funcs_vararg.ts") @@ -976,6 +977,7 @@ tslang_add_test(NAME test-jit-00-funcs-capture COMMAND test-runner -jit "${PROJE tslang_add_test(NAME test-jit-00-funcs-vararg COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00funcs_vararg.ts") tslang_add_test(NAME test-jit-00-declare-function-twice COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00declare_function_twice.ts") tslang_add_test(NAME test-jit-00-function-overloads COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00function_overloads.ts") +tslang_add_test(NAME test-jit-00-class-override-arity COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00class_override_arity.ts") tslang_add_test(NAME test-jit-00-gc-allocations-distinct COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00gc_allocations_distinct.ts") tslang_add_test(NAME test-jit-00-assert-message COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00assert_message.ts") tslang_add_test(NAME test-jit-01-funcs-vararg COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/01funcs_vararg.ts") @@ -2593,6 +2595,7 @@ set(TSLANG_CORPUS 00nested_implicit_generics.ts 00funcs_vararg.ts 00function_overloads.ts + 00class_override_arity.ts 00gc_allocations_distinct.ts 00assert_message.ts 00funcs.ts diff --git a/tslang/test/tester/tests/00class_override_arity.ts b/tslang/test/tester/tests/00class_override_arity.ts new file mode 100644 index 000000000..502c891f3 --- /dev/null +++ b/tslang/test/tester/tests/00class_override_arity.ts @@ -0,0 +1,85 @@ +// overrides of another arity than the method they override, called through the base class and +// through their own class, inside and outside the class bodies + +abstract class Base { + abstract get(): number; + + twice() { + return this.get() * 2; + } +} + +class Impl extends Base { + get(): number; + get(scale: number): number; + get(scale?: number) { + return scale === undefined ? 3 : 3 * scale; + } + + scaled() { + return this.get(5); + } +} + +class Deeper extends Impl { + get(scale?: number) { + return scale === undefined ? 4 : 4 * scale; + } +} + +class C { + foo(x: number, y: number, ...z: string[]): number { + return x + y + z.length; + } + + callFoo() { + return this.foo(1, 2, "a", "b"); + } +} + +class D extends C { + foo(): number { + return 100; + } + + callOwn() { + return this.foo(); + } +} + +class E extends D { + foo(x?: number): number { + return x === undefined ? 200 : 200 + x; + } +} + +function main() { + const b: Base = new Impl(); + assert(b.twice() == 6, "base slot calls get(scale?) with undefined"); + + const i = new Impl(); + assert(i.get() == 3); + assert(i.get(2) == 6, "own slot keeps the argument"); + assert(i.scaled() == 15, "call in the class body"); + + const deep: Base = new Deeper(); + assert(deep.twice() == 8, "base slot calls the latest override"); + const deepImpl: Impl = new Deeper(); + assert(deepImpl.get(2) == 8, "own slot of Impl calls Deeper's"); + assert(deepImpl.scaled() == 20); + + const c: C = new D(); + assert(c.callFoo() == 100, "extra arguments dropped"); + assert(c.foo(1, 2) == 100); + const d = new D(); + assert(d.foo() == 100); + assert(d.callOwn() == 100); + + const e: C = new E(); + assert(e.callFoo() == 201, "base slot of E calls foo(x?) with x"); + const ed: D = new E(); + assert(ed.foo() == 200, "D's slot calls E's with undefined"); + assert(new E().foo(5) == 205); + + print("done."); +}