Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to scriptc will be documented in this file.

### Features

- **Native callbacks can be retained and explicitly released.** FFI format 4 adds `lifetime: "retained"` registrations and paired `release` descriptors that reuse the original function-pointer trampoline. Registrations pin captured closures until the same function value is released, count duplicate registrations, support multiple context-bearing registrations and raw single-slot replacement, defer callback throws through later FFI pump calls, and clean up live registrations at process exit across both backends.
- **Native callbacks copy in strings and byte spans.** FFI format 3 adds callback-only `cstring` parameters plus length-delimited `string` and `bytes` parameters. Trampolines in both backends copy native memory into owned scriptc values, decode malformed UTF-8 with U+FFFD replacement, preserve embedded NUL bytes in spans, and trap precise invalid null pointers before invoking the closure.

<!-- release:start -->
Expand Down
70 changes: 61 additions & 9 deletions docs/src/app/ffi/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,21 @@ The manifest is the native ABI authority. TypeScript has only `number`, so its d
<td><code>cstring</code></td>
<td><code>string</code></td>
<td><code>const char *</code>; callback input only, copied through lossy UTF-8 decoding</td>
<td>callback only (format 3)</td>
<td>callback only (formats 3–4)</td>
<td>no</td>
</tr>
<tr>
<td><code>string</code></td>
<td><code>string</code></td>
<td><code>const uint8_t *, size_t</code>; UTF-8 bytes, length-delimited</td>
<td>yes; callback input in format 3</td>
<td>yes; callback input in formats 3–4</td>
<td>no</td>
</tr>
<tr>
<td><code>bytes</code></td>
<td><code>Uint8Array</code> or <code>Buffer</code></td>
<td><code>const uint8_t *, size_t</code>; raw bytes, length-delimited</td>
<td>yes; callback input in format 3</td>
<td>yes; callback input in formats 3–4</td>
<td>no</td>
</tr>
<tr>
Expand Down Expand Up @@ -189,24 +189,76 @@ The callback id connects the two independently positioned context entries. Both
}
```

A callback descriptor consumes one TypeScript function parameter and one native function-pointer slot. A context entry consumes no TypeScript parameter and one native `void *` slot. Formats 2 and 3 accept `f64`, `bool`, `u8`, `u32`, and `i32` callback parameters plus at most one context entry. Format 3 additionally accepts `cstring`, `string`, and `bytes`; callback returns remain scalar or `void`.
A callback descriptor consumes one TypeScript function parameter and one native function-pointer slot. A context entry consumes no TypeScript parameter and one native `void *` slot. Formats 2 through 4 accept `f64`, `bool`, `u8`, `u32`, and `i32` callback parameters plus at most one context entry. Formats 3 and 4 additionally accept `cstring`, `string`, and `bytes`; callback returns remain scalar or `void`.

Format 3 string-bearing callback parameters copy native data before the closure runs. `cstring` reads one non-null, NUL-terminated `const char *`. `string` and `bytes` each consume a `const uint8_t *, size_t` pair; a null pointer is valid only when its length is zero. Text is decoded as UTF-8 with malformed sequences replaced by U+FFFD, matching `Buffer.toString("utf8")`. The resulting string or `Uint8Array` is freshly owned scriptc storage, so the closure may retain it without depending on the native buffer's lifetime. An unexpected null `cstring`, or a null non-empty span, traps at the boundary instead of being treated as empty.

For a raw C callback type with no userdata, omit the context entry from both parameter lists. scriptc installs that closure in a binding-specific thread-local slot around the native call, so captures and nested calls still work. This remains call-scoped: the native function must invoke it synchronously on the thread that entered the native call.
For a raw call-scoped C callback type with no userdata, omit the context entry from both parameter lists. scriptc installs that closure in a binding-specific thread-local slot around the native call, so captures and nested calls still work.

`lifetime` must currently be `"call"`. Native code must not retain a callback or context, invoke it after the outer function returns, or invoke it from another thread. As with a bad native pointer or mismatched signature, violating that contract is outside scriptc's memory-safety guarantees. Retained callbacks will require an explicit registration/unregistration ownership model; foreign-thread callbacks will additionally require runtime scheduling and synchronization.
Format 4 adds `lifetime: "retained"` for same-thread native APIs that store a callback and invoke it from a later FFI call. Registration pins the closure and its captures. A paired release binding passes the same trampoline and closure context back to native code, then unpins one matching registration after the native call returns:

```ts:main.ts
declare function timerAdd(interval: number, tick: () => void): void;
declare function timerRemove(tick: () => void): void;

const tick = () => console.log("tick");
timerAdd(100, tick);
// A later native pump call may invoke tick here.
timerRemove(tick);
```

```json:ffi.json
{
"ffi_format": 4,
"functions": [
{
"name": "timerAdd",
"symbol": "timer_add",
"params": [
"u32",
{
"callback": {
"id": "tick",
"params": [{ "context": "tick" }],
"returns": "void",
"lifetime": "retained"
}
},
{ "context": "tick" }
],
"returns": "void"
},
{
"name": "timerRemove",
"symbol": "timer_remove",
"params": [
{ "callback": { "release": "timerAdd:tick" } },
{ "context": "timerAdd:tick" }
],
"returns": "void"
}
]
}
```

The release argument must be the same function value used for registration. Registrations to a context-bearing descriptor are counted: registering the same closure twice requires two releases. A raw descriptor's slot has replace semantics instead — every set call supersedes the previous registration, including one that passes the already-registered closure, so exactly one release is ever pending for that descriptor. Releasing an unregistered value traps because native code may still hold the original pointer — the trap fires before the native release call runs, so native code never observes the invalid release. The callback's function type must be exact at retained call sites; an implicit wrapper would create a different pointer and make release identity unsound. An inline function literal as a release argument is rejected for the same reason: it creates a fresh closure at every evaluation, a pointer no registration holds — pass the same named value used to register. Registering an inline literal remains legal; such a registration is simply permanent and is dropped by the exit teardown. A single binding cannot both register and release the same descriptor — the manifest loader rejects a `release` targeting a retained callback declared in the same function's parameter list, because the register-then-release ordering within one call would defeat the pre-call release validation.

Context-bearing descriptors support multiple concurrent closures. A raw retained descriptor has no context pointer, so it has one process-global slot with replace semantics: the previous registration stays live and dispatching until the replacing set call returns (a native setter that flushes the outgoing callback mid-replace still reaches the old closure), then it is released and the slot commits to the new closure. Retained registrations do not keep the event loop alive. At process exit, `process` `'exit'` listeners run first — they may still release or pump registrations on every exit path. On exits that run atexit handlers, the runtime then drops the remaining registrations and disarms raw slots; `process.exit()` terminates immediately after its listeners and skips that sweep, leaving remaining registrations to the operating system. A raw-slot invocation after teardown traps instead of reaching a freed closure. A context-bearing registration has no slot to disarm — its trampoline and context pointer dangle once teardown frees the closure — so native code must not invoke one after exit; a library that can fire on its own exit path should have its registrations released from a `process` `'exit'` listener.

Retained identity is scoped to the declaring binding. Every retained callback parameter is its own descriptor: the `<binding>:<callback-id>` pair names one registration ledger, one generated trampoline, and (for a raw descriptor) one slot, and a release binding validates and unpins only registrations made through the binding its `release` reference targets. Two bindings that store into the same native state — a plain setter and a flush-on-replace setter for one native slot, say — are therefore independent descriptors that pass native code two different function pointers. Registering the same function value through both and then releasing it through one is unsound: the release unpins in its own descriptor's ledger, but native code compares stored pointers against the other descriptor's trampoline, so the surviving registration stays armed and keeps dispatching — nothing traps, and the callback keeps firing after the program believes it released it. Keep a function value registered with one such native registration point through exactly one binding at a time, and release it through that binding's paired release.

Both `"call"` and `"retained"` callbacks must run synchronously on the script thread. Direct foreign-thread invocation, value-returning foreign-thread callbacks, and real-time callback delivery are unsupported: scriptc's reference counting and exception cell are thread-confined, and a future foreign-thread surface must enqueue fire-and-forget delivery onto the script loop. Dereferenceable struct callback parameters are also unsupported; use an opaque native handle with accessor functions when the API permits it.

If a callback throws, the adapter returns zero (or `void`) to native code and suppresses further script callback execution while the exception is pending. When the outer native function returns, the original exception resumes through scriptc's ordinary catchable unwind path. Native work performed between the callback's return and the outer function's return is not rolled back.

## Manifest fields

<dl>
<dt><code>ffi_format</code></dt>
<dd>Required. Format <code>1</code> supports value parameters; format <code>2</code> preserves them and adds callback/context entries; format <code>3</code> adds copy-in <code>cstring</code>, string-span, and byte-span callback parameters.</dd>
<dd>Required. Format <code>1</code> supports value parameters; format <code>2</code> preserves them and adds callback/context entries; format <code>3</code> adds copy-in <code>cstring</code>, string-span, and byte-span callback parameters; format <code>4</code> adds retained registrations and release references.</dd>

<dt><code>functions</code></dt>
<dd>Required array. Every entry has exactly <code>name</code>, <code>symbol</code>, <code>params</code>, and <code>returns</code>. Binding names and symbols must be unique. In formats 2 and 3, callback ids must be unique within a function and every context must match exactly one callback.</dd>
<dd>Required array. Every entry has exactly <code>name</code>, <code>symbol</code>, <code>params</code>, and <code>returns</code>. Binding names and symbols must be unique. Callback ids must be unique within a function and every context must match exactly one callback or release. A release references a retained <code>&lt;binding&gt;:&lt;callback-id&gt;</code> in the same manifest and inherits its callback ABI.</dd>

<dt><code>libraries</code></dt>
<dd>Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.</dd>
Expand All @@ -221,7 +273,7 @@ Unknown fields, invalid ABI classes, duplicate names, and signature mismatches f

- Native calls are synchronous and must return normally. Do not unwind C++ exceptions or `longjmp` across the boundary.
- Native code is outside scriptc's exception, reference-counting, and sanitizer contracts. A bad pointer or mismatched C signature can still corrupt the process.
- Callbacks are synchronous, call-scoped, and same-thread only. Retained callbacks and foreign-thread invocation are not supported yet.
- Callbacks are same-thread only. Format 4 supports retained callbacks with explicit release; foreign-thread invocation is not supported.
- There are no variadic calls, struct-by-value arguments, owned pointer returns, or runtime `dlopen`/`dlsym` handles yet.
- The archive or object must match the build target. Cross-compilation does not translate native inputs.
- Outbound FFI is currently available for executable builds, not `scriptc build --lib`.
2 changes: 1 addition & 1 deletion docs/src/app/limitations/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,5 @@ The production <code>wasm32-wasi</code> target supports the complete executable
## Tooling gaps

- `scriptc run` does not forward extra CLI arguments to the program — `build` and invoke the binary directly.
- Native FFI is a direct, manifest-declared C ABI link surface. Callback parameters are synchronous, call-scoped, and same-thread; retained or foreign-thread callbacks are not supported yet. Variadic calls, structs by value, owned pointer/string/byte returns, runtime dynamic-library loading, and library-mode builds also remain unsupported. See [Native FFI](/ffi).
- Native FFI is a direct, manifest-declared C ABI link surface. Callback invocation is same-thread only; format 4 supports explicitly released retained callbacks, but foreign-thread callbacks remain unsupported. Variadic calls, structs by value, owned pointer/string/byte returns, runtime dynamic-library loading, and library-mode builds also remain unsupported. See [Native FFI](/ffi).
- Numbers are JS-exact f64 everywhere. Integer inference and ownership analysis — the systems-language performance ceiling — are roadmap, not shipped.
6 changes: 4 additions & 2 deletions packages/compiler/src/backend/cc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function stableTestMemo<T>(
return pending;
}

const RUNTIME_SOURCES = ["scr_number.c", "scr_string.c", "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", "scr_map.c", "scr_closure.c", "scr_object.c", "scr_union.c", "scr_exception.c", "scr_error.c", "scr_console.c", "scr_lib.c", "scr_path.c", "scr_url.c", "scr_json.c", "scr_async.c", "scr_child.c", "scr_cycle.c"];
const RUNTIME_SOURCES = ["scr_number.c", "scr_string.c", "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", "scr_object.c", "scr_union.c", "scr_exception.c", "scr_error.c", "scr_console.c", "scr_lib.c", "scr_path.c", "scr_url.c", "scr_json.c", "scr_async.c", "scr_child.c", "scr_cycle.c"];

/** The pinned quickjs-ng snapshot under packages/runtime/vendor/quickjs-ng
* (see vendor/README.md — update both together). Keys the archive cache so
Expand Down Expand Up @@ -1385,7 +1385,9 @@ async function ensureTlsArchive(
/** The library base: the executable lane's unconditional sources minus the
* fiber/loop and child-process units, plus the library-mode TU. */
const LIB_RUNTIME_SOURCES = [
...RUNTIME_SOURCES.filter((f) => f !== "scr_async.c" && f !== "scr_child.c"),
...RUNTIME_SOURCES.filter(
(f) => f !== "scr_async.c" && f !== "scr_child.c" && f !== "scr_ffi.c",
),
"scr_library.c",
];

Expand Down
Loading
Loading