Files
scriptc/tests/harness/ffi.test.ts
Chris TateandDemonGPT adea269659 feat(ffi): support call-scoped native callbacks (#128)
* feat(ffi): support call-scoped native callbacks

- Add format-2 callback/context declarations with exact ABI positions and strict lifetime validation.
- Emit capture-aware C and LLVM trampolines with scalar coercion and exception propagation.
- Document the callback contract and cover both backends in plain and sanitized tests.

Co-authored-by: DemonGPT <127974011+DemonGPT@users.noreply.github.com>

* fix FFI callback safety

---------

Co-authored-by: DemonGPT <127974011+DemonGPT@users.noreply.github.com>
2026-08-11 08:25:29 -05:00

477 lines
14 KiB
TypeScript

/* Outbound native FFI is an integration lane rather than a corpus case:
* Node has no equivalent static-link surface to differential-run. The same
* TypeScript and native archive run through BOTH scriptc backends, and the
* result bytes must match. The fixture covers every value ABI class,
* integer coercion, embedded-NUL/UTF-8 string spans, byte spans, and format-2
* raw/context callbacks (captures, exact context positions, conversion,
* mutation, and catchable throws). */
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { compile, loadFfiProfile } from "@scriptc/compiler";
const repoRoot = join(import.meta.dirname, "../..");
const fixtureRoot = join(repoRoot, "tests/ffi");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const flavor = sanitize ? "san" : "plain";
const cacheRoot = join(
repoRoot,
"node_modules/.cache/scriptc-tests/ffi",
flavor,
);
function nativeArchive(): string {
const outDir = join(cacheRoot, "native");
mkdirSync(outDir, { recursive: true });
const object = join(outDir, "native.o");
const archive = join(outDir, "libnative.a");
execFileSync("clang", [
"-std=c11",
...(sanitize ? ["-O1", "-fsanitize=address"] : ["-O2"]),
"-c",
join(fixtureRoot, "native.c"),
"-o",
object,
]);
execFileSync("ar", ["rcs", archive, object]);
return archive;
}
function manifest(archive: string): string {
const outDir = join(cacheRoot, "manifest");
mkdirSync(outDir, { recursive: true });
const profile = JSON.parse(
readFileSync(join(fixtureRoot, "profile.json"), "utf8"),
) as { libraries: string[] };
profile.libraries = [archive];
const path = join(outDir, "profile.json");
writeFileSync(path, JSON.stringify(profile, null, 2));
return path;
}
const expected = [
"42",
"true false",
"2 4294967295 -1",
"429",
"6",
"12.5",
"12",
"28",
"9",
"42",
"true 255 4000000000 -7 0.5",
"4294967295",
"6",
"caught callback boom",
"",
].join("\n");
describe.each(["c", "llvm"] as const)("outbound native FFI, %s backend", (backend) => {
test("calls the manifest-bound archive across every v1 ABI class plus v2 callback ABI classes", async () => {
const outDir = join(cacheRoot, backend);
mkdirSync(outDir, { recursive: true });
const result = await compile(join(fixtureRoot, "main.ts"), {
outDir,
outPath: join(outDir, "program"),
backend,
sanitize,
ffiProfilePath: manifest(nativeArchive()),
});
if (!result.ok) {
throw new Error(
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
expect(
execFileSync(result.binaryPath, [], { encoding: "utf8" }),
).toBe(expected);
});
});
test("manifest validation is strict and source-facing", () => {
const path = join(cacheRoot, "invalid.json");
mkdirSync(cacheRoot, { recursive: true });
writeFileSync(
path,
JSON.stringify({
ffi_format: 1,
functions: [
{ name: "f", symbol: "not-a-C-symbol", params: [], returns: "f64" },
],
}),
);
const result = loadFfiProfile(path);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]?.code).toBe("SC5001");
expect(result.diagnostics[0]?.message).toContain("not a C identifier");
}
});
test("manifest validation reports a missing native link input", () => {
const path = join(cacheRoot, "missing-library.json");
mkdirSync(cacheRoot, { recursive: true });
writeFileSync(
path,
JSON.stringify({
ffi_format: 1,
functions: [],
libraries: ["does-not-exist.a"],
}),
);
const result = loadFfiProfile(path);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]?.code).toBe("SC5001");
expect(result.diagnostics[0]?.message).toContain("cannot be read");
}
});
test.each([
{
name: "a callback in format 1",
profile: {
ffi_format: 1,
functions: [{
name: "visit",
symbol: "sf_visit",
params: [{ callback: { id: "visit", params: ["f64"], returns: "void", lifetime: "call" } }],
returns: "void",
}],
},
message: "must be one of",
},
{
name: "an orphaned context",
profile: {
ffi_format: 2,
functions: [{ name: "visit", symbol: "sf_visit", params: [{ context: "visit" }], returns: "void" }],
},
message: "has no matching callback",
},
{
name: "a context present on only one side",
profile: {
ffi_format: 2,
functions: [{
name: "visit",
symbol: "sf_visit",
params: [{
callback: {
id: "visit",
params: ["f64", { context: "visit" }],
returns: "void",
lifetime: "call",
},
}],
returns: "void",
}],
},
message: "exactly once in both",
},
{
name: "a retained callback lifetime",
profile: {
ffi_format: 2,
functions: [{
name: "visit",
symbol: "sf_visit",
params: [{
callback: { id: "visit", params: ["f64"], returns: "void", lifetime: "retained" },
}],
returns: "void",
}],
},
message: "lifetime' must be 'call",
},
])("manifest validation rejects $name", ({ name, profile, message }) => {
const path = join(cacheRoot, `invalid-callback-${name.replaceAll(" ", "-")}.json`);
mkdirSync(cacheRoot, { recursive: true });
writeFileSync(path, JSON.stringify(profile));
const result = loadFfiProfile(path);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]?.code).toBe("SC5001");
expect(result.diagnostics[0]?.message).toContain(message);
}
});
test.each([
{
id: "called-signature",
name: "a TypeScript signature that disagrees with the manifest",
source: [
"declare function nativeScale(value: string): number;",
"console.log(nativeScale('21'));",
"",
].join("\n"),
code: "SC5003",
message: "parameter 1",
},
{
id: "called-body",
name: "an ordinary function body with a configured name",
source: [
"function nativeScale(value: number): number { return value; }",
"console.log(nativeScale(21));",
"",
].join("\n"),
code: "SC5002",
message: "signature-only",
},
{
id: "unused-signature",
name: "an unused TypeScript signature that disagrees with the manifest",
source: [
"declare function nativeScale(value: string): number;",
"console.log('ok');",
"",
].join("\n"),
code: "SC5003",
message: "parameter 1",
},
{
id: "unused-missing",
name: "an unused manifest binding with no source declaration",
source: [
"console.log('ok');",
"",
].join("\n"),
code: "SC5002",
message: "signature-only",
},
{
id: "never-parameter",
name: "an uninhabited TypeScript parameter presented as a native ABI slot",
source: [
"declare function nativeScale(value: never): number;",
"console.log('ok');",
"",
].join("\n"),
code: "SC5003",
message: "parameter 1 is 'never'",
},
{
id: "never-return",
name: "a TypeScript never return that the native function cannot uphold",
source: [
"declare function nativeScale(value: number): never;",
"console.log('ok');",
"",
].join("\n"),
code: "SC5003",
message: "return type is 'never'",
},
])("rejects $name", async ({ id, source, code, message }) => {
const outDir = join(cacheRoot, `reject-${id}`);
mkdirSync(outDir, { recursive: true });
const entry = join(outDir, "main.ts");
const profilePath = join(outDir, "profile.json");
writeFileSync(entry, source);
writeFileSync(
profilePath,
JSON.stringify({
ffi_format: 1,
functions: [
{
name: "nativeScale",
symbol: "sf_scale",
params: ["f64"],
returns: "f64",
},
],
}),
);
const result = await compile(entry, {
outDir,
outPath: join(outDir, "never"),
ffiProfilePath: profilePath,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]?.code).toBe(code);
expect(result.diagnostics[0]?.message).toContain(message);
}
});
test.each([
{ id: "number-literal", type: "0", nativeClass: "f64", domain: "number", prefix: null },
{
id: "numeric-enum",
type: "NativeValue",
nativeClass: "f64",
domain: "number",
prefix: "enum NativeValue { Zero }",
},
{ id: "never", type: "never", nativeClass: "f64", domain: "number", prefix: null },
{ id: "boolean-literal", type: "true", nativeClass: "bool", domain: "boolean", prefix: null },
])(
"rejects a narrowed $id native-to-script callback parameter",
async ({ id, type, nativeClass, domain, prefix }) => {
const outDir = join(cacheRoot, `reject-callback-${id}`);
mkdirSync(outDir, { recursive: true });
const entry = join(outDir, "main.ts");
const profilePath = join(outDir, "profile.json");
writeFileSync(
entry,
[
...(prefix === null ? [] : [prefix]),
`declare function nativeVisit(callback: (value: ${type}) => void): void;`,
"console.log('ok');",
"",
].join("\n"),
);
writeFileSync(
profilePath,
JSON.stringify({
ffi_format: 2,
functions: [{
name: "nativeVisit",
symbol: "sf_visit",
params: [{
callback: {
id: "visit",
params: [nativeClass],
returns: "void",
lifetime: "call",
},
}],
returns: "void",
}],
}),
);
const result = await compile(entry, {
outDir,
outPath: join(outDir, "program"),
ffiProfilePath: profilePath,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics[0]?.code).toBe("SC5003");
expect(result.diagnostics[0]?.message).toContain(
`callback 'visit' parameter 1 is '${type}'`,
);
expect(result.diagnostics[0]?.message).toContain(`declare it as '${domain}'`);
}
},
);
describe.each(["c", "llvm"] as const)("FFI binding identity, %s backend", (backend) => {
test("a local same-named function remains ordinary TypeScript with Node-byte parity", async () => {
const outDir = join(cacheRoot, `shadow-${backend}`);
mkdirSync(outDir, { recursive: true });
// Node 24 deliberately refuses its built-in TypeScript stripping for
// files below node_modules, where this suite keeps compiler artifacts.
// Keep the reference input in the OS temp tree and only outputs/cache in
// node_modules.
const sourceDir = mkdtempSync(join(tmpdir(), "scriptc-ffi-shadow-"));
const entry = join(sourceDir, "main.ts");
const profilePath = join(outDir, "profile.json");
try {
writeFileSync(
entry,
[
"declare function nativeScale(value: number): number;",
"function localUse(): number {",
" function nativeScale(value: number): number { return value + 1; }",
" return nativeScale(21);",
"}",
"console.log(localUse());",
"",
].join("\n"),
);
writeFileSync(
profilePath,
JSON.stringify({
ffi_format: 1,
functions: [
{
name: "nativeScale",
symbol: "sf_scale",
params: ["f64"],
returns: "f64",
},
],
libraries: [nativeArchive()],
}),
);
const result = await compile(entry, {
outDir,
outPath: join(outDir, "program"),
backend,
sanitize,
ffiProfilePath: profilePath,
});
if (!result.ok) {
throw new Error(
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
const node = spawnSync(process.execPath, [entry], { encoding: "utf8" });
const native = spawnSync(result.binaryPath, [], { encoding: "utf8" });
expect({
stdout: native.stdout,
stderr: native.stderr,
status: native.status,
}).toEqual({
stdout: node.stdout,
stderr: node.stderr,
status: node.status,
});
} finally {
rmSync(sourceDir, { recursive: true, force: true });
}
});
});
test("a missing FFI symbol is an SC5004 diagnostic, not a rejected compile", async () => {
const outDir = join(cacheRoot, "missing-symbol");
mkdirSync(outDir, { recursive: true });
const entry = join(outDir, "main.ts");
const profilePath = join(outDir, "profile.json");
const missingSymbol = "sf_symbol_that_does_not_exist";
writeFileSync(
entry,
[
"declare function nativeScale(value: number): number;",
"console.log(nativeScale(21));",
"",
].join("\n"),
);
writeFileSync(
profilePath,
JSON.stringify({
ffi_format: 1,
functions: [
{
name: "nativeScale",
symbol: missingSymbol,
params: ["f64"],
returns: "f64",
},
],
libraries: [nativeArchive()],
}),
);
const result = await compile(entry, {
outDir,
outPath: join(outDir, "program"),
sanitize,
ffiProfilePath: profilePath,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.code).toBe("SC5004");
expect(result.diagnostics[0]?.message).toContain(missingSymbol);
expect(result.diagnostics[0]?.message).not.toContain("CcCompileError");
}
});