@scriptc/runtime 0.0.32 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scriptc/runtime",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "scriptc native runtime — C sources, compiled into every scriptc binary",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scriptc.dev",
package/src/scr_async.c CHANGED
@@ -2198,6 +2198,22 @@ void scr_loop_set_watch(bool (*pending)(void), void (*dispatch)(void), int (*pol
2198
2198
  scr_watch_pollfd_fn = pollfd;
2199
2199
  }
2200
2200
 
2201
+ /* The foreign-FFI queue hook (scr_ffi.c when a format-5 descriptor exists).
2202
+ * Its pending count includes live registrations (reffed by default) and
2203
+ * queued deliveries; dispatch runs exactly one callback per loop turn. */
2204
+ static bool (*scr_ffi_pending_fn)(void) = NULL;
2205
+ static bool (*scr_ffi_dispatch_fn)(void) = NULL;
2206
+ static int (*scr_ffi_pollfd_fn)(void) = NULL;
2207
+ static void (*scr_ffi_stop_fn)(void) = NULL;
2208
+
2209
+ void scr_loop_set_ffi(bool (*pending)(void), bool (*dispatch)(void),
2210
+ int (*pollfd)(void), void (*stop)(void)) {
2211
+ scr_ffi_pending_fn = pending;
2212
+ scr_ffi_dispatch_fn = dispatch;
2213
+ scr_ffi_pollfd_fn = pollfd;
2214
+ scr_ffi_stop_fn = stop;
2215
+ }
2216
+
2201
2217
  /* The stream hook (scr_stream.c, when linked): `pending` keeps the loop
2202
2218
  * alive while deferred stream ticks exist, `dispatch` drains them at the
2203
2219
  * TOP of every turn — before the events/net stations, the closest
@@ -2295,6 +2311,12 @@ bool scr_loop_run(ScrPromise *top_level) {
2295
2311
  if (scr_exc_pending()) return false;
2296
2312
  if (dispatched) continue;
2297
2313
  }
2314
+ /* Foreign native callbacks are macrotasks. Deliver one, then restart at
2315
+ * the microtask checkpoint before considering the next queued post. */
2316
+ if (scr_ffi_dispatch_fn != NULL && scr_ffi_dispatch_fn()) {
2317
+ if (scr_exc_pending()) return false;
2318
+ if (scr_ready_len > 0 || scr_nt_head != NULL || scr_nunhandled > 0) continue;
2319
+ }
2298
2320
  /* Stream tick dispatch (scr_stream.c, when linked): the deferred
2299
2321
  * next-tick emissions ('data' flow kicks, 'readable'/'end'/'finish'/
2300
2322
  * 'drain'/'error'/'close') fire now, FIRST — the nextTick station.
@@ -2363,6 +2385,7 @@ bool scr_loop_run(ScrPromise *top_level) {
2363
2385
  (scr_net_pending_fn != NULL && scr_net_pending_fn()) ||
2364
2386
  (scr_dgram_pending_fn != NULL && scr_dgram_pending_fn()) ||
2365
2387
  (scr_watch_pending_fn != NULL && scr_watch_pending_fn()) ||
2388
+ (scr_ffi_pending_fn != NULL && scr_ffi_pending_fn()) ||
2366
2389
  scr_fs_renames_pending();
2367
2390
  if (held) {
2368
2391
  scr_children_poll();
@@ -2382,6 +2405,7 @@ bool scr_loop_run(ScrPromise *top_level) {
2382
2405
  bool net = scr_net_pending_fn != NULL && scr_net_pending_fn();
2383
2406
  bool dgram = scr_dgram_pending_fn != NULL && scr_dgram_pending_fn();
2384
2407
  bool watch = scr_watch_pending_fn != NULL && scr_watch_pending_fn();
2408
+ bool ffi = scr_ffi_pending_fn != NULL && scr_ffi_pending_fn();
2385
2409
  bool renames = scr_fs_renames_pending();
2386
2410
  /* Timer liveness counts only REF'd timers: an unref'd timer stays in
2387
2411
  * the heap (and fires if the loop runs on for other reasons) but does
@@ -2389,7 +2413,7 @@ bool scr_loop_run(ScrPromise *top_level) {
2389
2413
  * Children follow the same rule: an unref'd child is still REAPED
2390
2414
  * while the loop runs (kids drives the sweeps and sleeps above) but
2391
2415
  * only reffed ones keep the process alive. */
2392
- if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !watch && !renames) break;
2416
+ if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !watch && !ffi && !renames) break;
2393
2417
  /* Sleep to the earliest deadline, then run every due timer (each may
2394
2418
  * enqueue microtasks, which the next iteration drains first). Who
2395
2419
  * sleeps depends on what is pending:
@@ -2430,11 +2454,11 @@ bool scr_loop_run(ScrPromise *top_level) {
2430
2454
  * on EINTR), so they re-impose a coarser cap — bounded Ctrl-C and
2431
2455
  * socket latency during a fetch, without the reap-granularity
2432
2456
  * cost. */
2433
- else if ((evw || net || dgram || watch) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
2457
+ else if ((evw || net || dgram || watch || ffi) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
2434
2458
  scr_io_poll_fn(due > now ? due - now : 0);
2435
2459
  now = scr_now_ms();
2436
2460
  if (scr_ready_len > 0) continue; /* io callbacks woke fibers */
2437
- } else if (evw || net || dgram || watch) {
2461
+ } else if (evw || net || dgram || watch || ffi) {
2438
2462
  #if defined(_WIN32) || defined(__wasi__)
2439
2463
  /* The win32 arm, and WASI hosts whose poll_oneoff adapters do not
2440
2464
  * reliably wake for a closed inherited stdin pipe: the sleep is a capped nanosleep and
@@ -2449,6 +2473,7 @@ bool scr_loop_run(ScrPromise *top_level) {
2449
2473
  * WaitForMultipleObjects over WSAEVENTs, or IOCP. */
2450
2474
  if (evw && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS;
2451
2475
  if ((net || dgram || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
2476
+ if (ffi && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
2452
2477
  if (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS;
2453
2478
  if (due > now) {
2454
2479
  double wait = due - now;
@@ -2465,7 +2490,7 @@ bool scr_loop_run(ScrPromise *top_level) {
2465
2490
  * events are pending); unrepresentable children keep the ~1ms reap cap
2466
2491
  * instead. Dispatch happens at the next turn's top — the poll only
2467
2492
  * decides how long to sleep. */
2468
- struct pollfd fds[6];
2493
+ struct pollfd fds[7];
2469
2494
  int nfds = 0;
2470
2495
  int evfds[2];
2471
2496
  int nev = evw && scr_events_pollfds_fn != NULL ? scr_events_pollfds_fn(evfds) : 0;
@@ -2510,6 +2535,16 @@ bool scr_loop_run(ScrPromise *top_level) {
2510
2535
  due = now + SCR_SIGNAL_POLL_MS;
2511
2536
  }
2512
2537
  }
2538
+ if (ffi) {
2539
+ int ffd = scr_ffi_pollfd_fn != NULL ? scr_ffi_pollfd_fn() : -1;
2540
+ if (ffd >= 0) {
2541
+ fds[nfds].fd = ffd;
2542
+ fds[nfds].events = POLLIN;
2543
+ fds[nfds++].revents = 0;
2544
+ } else if (due > now + SCR_CHILD_POLL_MS) {
2545
+ due = now + SCR_CHILD_POLL_MS;
2546
+ }
2547
+ }
2513
2548
  if (kids) {
2514
2549
  int cfd = scr_children_wake_fd();
2515
2550
  if (cfd >= 0) {
@@ -2618,7 +2653,21 @@ bool scr_loop_run(ScrPromise *top_level) {
2618
2653
  * them as leaks. Uncaught callback throws still return above for main
2619
2654
  * to report through the existing exceptional teardown path. */
2620
2655
  scr_timers_teardown();
2621
- /* Same story for unref'd children the loop never reaped: release the
2656
+ /* No reffed foreign registration or queued invocation remains at ordinary
2657
+ * exhaustion. Disarm posting before exit listeners/global teardown so a
2658
+ * straggling native thread becomes a safe silent drop. */
2659
+ if (scr_ffi_stop_fn != NULL) scr_ffi_stop_fn();
2660
+ /* Retained native FFI registrations are deliberately NOT torn down
2661
+ * here: process 'exit' listeners run after the loop returns (inline in
2662
+ * main, before any atexit handler) and may legitimately release a
2663
+ * registration or pump a native library one last time — exactly like
2664
+ * the process.exit() path, where the ledger is also still intact. On
2665
+ * returns that reach atexit, the sweep scr_ffi_retain registered (LIFO
2666
+ * before the RC audit) then drops the ledger's references. It does NOT
2667
+ * run on process.exit(): scr_process_exit ends in _Exit, skipping every
2668
+ * atexit handler — the sweep and the RC audit alike — and leaves the
2669
+ * ledger to the OS. */
2670
+ /* Unref'd children the loop never reaped: release the
2622
2671
  * registry's references (their listeners never fire — the process is
2623
2672
  * exiting, Node's behavior; the OS reparents the children). */
2624
2673
  scr_children_teardown();
package/src/scr_bytes.c CHANGED
@@ -63,6 +63,15 @@ ScrBytes *scr_bytes_new(ScrBytesElem elem, double n) {
63
63
  return scr_bytes_alloc(elem, (size_t)t);
64
64
  }
65
65
 
66
+ ScrBytes *scr_bytes_from_data(const uint8_t *data, size_t len) {
67
+ if (data == NULL && len != 0) {
68
+ scr_trap("scriptc: native callback passed a NULL span with nonzero length\n");
69
+ }
70
+ ScrBytes *b = scr_bytes_alloc(SCR_BYTES_U8, len);
71
+ if (len != 0) memcpy(b->data, data, len);
72
+ return b;
73
+ }
74
+
66
75
  ScrBytes *scr_bytes_copy(const ScrBytes *src) {
67
76
  ScrBytes *b = scr_bytes_alloc(src->elem, src->len);
68
77
  memcpy(b->data, src->data, src->len * scr_bytes_elem_size(src->elem));
@@ -410,6 +419,10 @@ static const char scr_hex_digits[] = "0123456789abcdef";
410
419
  * — what Buffer.prototype.toString("utf8") does. Output re-encodes as
411
420
  * (now valid) UTF-8: worst case 3 bytes per input byte. */
412
421
  static ScrStr *scr_bytes_decode_utf8(const uint8_t *in, size_t n) {
422
+ if (in == NULL && n != 0) {
423
+ scr_trap("scriptc: native callback passed a NULL span with nonzero length\n");
424
+ }
425
+ if (n > (SIZE_MAX - 1) / 3) scr_bytes_oom();
413
426
  char *out = malloc(n * 3 + 1);
414
427
  if (!out) scr_bytes_oom();
415
428
  size_t o = 0;
@@ -478,6 +491,10 @@ static ScrStr *scr_bytes_decode_utf8(const uint8_t *in, size_t n) {
478
491
  return s;
479
492
  }
480
493
 
494
+ ScrStr *scr_str_from_utf8_lossy(const uint8_t *bytes, size_t len) {
495
+ return scr_bytes_decode_utf8(bytes, len);
496
+ }
497
+
481
498
  /* WHATWG TextDecoder.decode (utf-8, default options): the SAME maximal-
482
499
  * subpart replacement decode as toString("utf8") above, with one
483
500
  * difference — a leading UTF-8 BOM is stripped (ignoreBOM defaults to
package/src/scr_ffi.c ADDED
@@ -0,0 +1,139 @@
1
+ #include "scr_runtime.h"
2
+
3
+ #include <stdlib.h>
4
+
5
+ /* The process-global retained-registration ledger. Format-4 script-thread
6
+ * tables use the lock-free path in this always-linked unit. Format-5 foreign
7
+ * tables install an optional teardown hook owned by scr_ffi_queue.c, keeping
8
+ * all queue/thread machinery out of unrelated binaries. */
9
+ static ScrFfiTable *scr_ffi_tables;
10
+ static bool scr_ffi_exit_registered;
11
+
12
+ static void scr_ffi_oom(void) { scr_trap("scriptc: out of memory\n"); }
13
+
14
+ void scr_ffi_link(ScrFfiTable *table) {
15
+ if (!table->linked) {
16
+ table->linked = true;
17
+ table->next = scr_ffi_tables;
18
+ scr_ffi_tables = table;
19
+ }
20
+ if (!scr_ffi_exit_registered) {
21
+ scr_ffi_exit_registered = true;
22
+ scr_atexit(scr_ffi_teardown_all);
23
+ }
24
+ }
25
+
26
+ void scr_ffi_teardown(ScrFfiTable *table) {
27
+ if (table->teardown != NULL) {
28
+ table->teardown(table);
29
+ return;
30
+ }
31
+ /* Disarm the raw trampoline slot FIRST: a closure release below cannot
32
+ * run script code today, but the slot must never dangle over freed
33
+ * entries — a native exit-path invocation takes the NULL trap instead. */
34
+ if (table->slot != NULL) *table->slot = NULL;
35
+ for (size_t i = 0; i < table->len; i++) {
36
+ scr_closure_release(table->entries[i]);
37
+ }
38
+ free(table->entries);
39
+ table->entries = NULL;
40
+ table->len = 0;
41
+ table->cap = 0;
42
+ }
43
+
44
+ void scr_ffi_teardown_all(void) {
45
+ for (ScrFfiTable *table = scr_ffi_tables; table != NULL; table = table->next) {
46
+ scr_ffi_teardown(table);
47
+ }
48
+ }
49
+
50
+ void scr_ffi_retain(ScrFfiTable *table, ScrClosure *callback) {
51
+ scr_ffi_link(table);
52
+ if (table->len == table->cap) {
53
+ size_t cap = table->cap == 0 ? 4 : table->cap * 2;
54
+ if (cap < table->cap || cap > SIZE_MAX / sizeof *table->entries) scr_ffi_oom();
55
+ ScrClosure **entries = realloc(table->entries, cap * sizeof *entries);
56
+ if (entries == NULL) scr_ffi_oom();
57
+ table->entries = entries;
58
+ table->cap = cap;
59
+ }
60
+ table->entries[table->len++] = scr_closure_retain(callback);
61
+ }
62
+
63
+ /* Raw singleton registration runs in two halves around the native set
64
+ * call. The first half pins the incoming closure and records the slot,
65
+ * but leaves the CURRENT registration untouched: a native setter may
66
+ * flush the outgoing callback one last time mid-replace, and it must
67
+ * dispatch a live closure. An empty slot arms immediately so a native
68
+ * fire-on-subscribe during the set call reaches the new closure. */
69
+ void scr_ffi_retain_slot(ScrFfiTable *table, ScrClosure **slot, ScrClosure *callback) {
70
+ table->slot = slot;
71
+ scr_ffi_retain(table, callback);
72
+ if (*slot == NULL) *slot = callback;
73
+ }
74
+
75
+ /* The second half, after the native call returns: point the slot at the
76
+ * new registration and retire every pin it superseded. A callback pumped
77
+ * during the native call may have released or replaced this registration
78
+ * already (nested rawSet/rawRemove) — commit only a still-live entry. */
79
+ void scr_ffi_commit_slot(ScrFfiTable *table, ScrClosure *callback) {
80
+ bool live = false;
81
+ for (size_t i = 0; i < table->len; i++) {
82
+ if (table->entries[i] == callback) {
83
+ live = true;
84
+ break;
85
+ }
86
+ }
87
+ if (!live) return;
88
+ *table->slot = callback;
89
+ bool kept = false;
90
+ size_t i = 0;
91
+ while (i < table->len) {
92
+ ScrClosure *entry = table->entries[i];
93
+ if (entry == callback && !kept) {
94
+ kept = true;
95
+ i++;
96
+ continue;
97
+ }
98
+ /* Swap-remove BEFORE releasing so the table stays consistent while
99
+ * the release runs; the swapped-in tail entry is re-examined at i. */
100
+ table->len--;
101
+ table->entries[i] = table->entries[table->len];
102
+ scr_closure_release(entry);
103
+ }
104
+ }
105
+
106
+ /* Pre-call validation for an explicit release: emitted before the native
107
+ * removal call, so releasing an unregistered value traps before native
108
+ * code can act on the bogus pointer pair. */
109
+ void scr_ffi_require(ScrFfiTable *table, ScrClosure *callback) {
110
+ for (size_t i = 0; i < table->len; i++) {
111
+ if (table->entries[i] == callback) return;
112
+ }
113
+ scr_trap("scriptc: releasing a native callback registration that does not exist\n");
114
+ }
115
+
116
+ void scr_ffi_release(ScrFfiTable *table, ScrClosure *callback) {
117
+ for (size_t i = 0; i < table->len; i++) {
118
+ if (table->entries[i] != callback) continue;
119
+ ScrClosure *owned = table->entries[i];
120
+ table->len--;
121
+ if (i != table->len) table->entries[i] = table->entries[table->len];
122
+ /* If the raw slot dispatched to this registration and no duplicate
123
+ * pin remains, disarm it — the trampoline must not reach a released
124
+ * closure. */
125
+ if (table->slot != NULL && *table->slot == callback) {
126
+ bool remaining = false;
127
+ for (size_t j = 0; j < table->len; j++) {
128
+ if (table->entries[j] == callback) {
129
+ remaining = true;
130
+ break;
131
+ }
132
+ }
133
+ if (!remaining) *table->slot = NULL;
134
+ }
135
+ scr_closure_release(owned);
136
+ return;
137
+ }
138
+ scr_trap("scriptc: releasing a native callback registration that does not exist\n");
139
+ }
@@ -0,0 +1,379 @@
1
+ #include "scr_runtime.h"
2
+
3
+ #include <stdint.h>
4
+ #include <stdio.h>
5
+ #include <stdlib.h>
6
+ #include <string.h>
7
+
8
+ #ifdef _WIN32
9
+ #include <windows.h>
10
+ #elif defined(__wasi__)
11
+ /* Native FFI is rejected for WASI. This keeps direct runtime builds honest. */
12
+ #else
13
+ #include <fcntl.h>
14
+ #include <pthread.h>
15
+ #include <unistd.h>
16
+ #endif
17
+
18
+ typedef enum {
19
+ SCR_FFI_ARG_NONE = 0,
20
+ SCR_FFI_ARG_F64,
21
+ SCR_FFI_ARG_BOOL,
22
+ SCR_FFI_ARG_U8,
23
+ SCR_FFI_ARG_U32,
24
+ SCR_FFI_ARG_I32,
25
+ SCR_FFI_ARG_DATA,
26
+ } ScrFfiArgKind;
27
+
28
+ typedef struct {
29
+ ScrFfiArgKind kind;
30
+ union {
31
+ double f64;
32
+ uint32_t u32;
33
+ int32_t i32;
34
+ struct {
35
+ uint8_t *data;
36
+ size_t len;
37
+ } span;
38
+ } value;
39
+ } ScrFfiArg;
40
+
41
+ struct ScrFfiCall {
42
+ ScrFfiTable *table; /* opaque on the posting thread */
43
+ void *loop; /* reserved instance/loop identity */
44
+ ScrClosure *callback; /* opaque; the table owns its script RC pin */
45
+ ScrFfiDispatchFn dispatch;
46
+ size_t nargs;
47
+ ScrFfiArg *args;
48
+ struct ScrFfiCall *next;
49
+ };
50
+
51
+ static ScrFfiCall *scr_ffi_head;
52
+ static ScrFfiCall *scr_ffi_tail;
53
+ static size_t scr_ffi_foreign_registrations;
54
+ static bool scr_ffi_installed;
55
+ static bool scr_ffi_stopping;
56
+ static int scr_ffi_wake_pipe[2] = {-1, -1};
57
+
58
+ #ifdef _WIN32
59
+ static SRWLOCK scr_ffi_lock = SRWLOCK_INIT;
60
+ static void scr_ffi_lock_enter(void) { AcquireSRWLockExclusive(&scr_ffi_lock); }
61
+ static void scr_ffi_lock_leave(void) { ReleaseSRWLockExclusive(&scr_ffi_lock); }
62
+ #elif defined(__wasi__)
63
+ static void scr_ffi_lock_enter(void) {}
64
+ static void scr_ffi_lock_leave(void) {}
65
+ #else
66
+ static pthread_mutex_t scr_ffi_lock = PTHREAD_MUTEX_INITIALIZER;
67
+ static void scr_ffi_lock_enter(void) { (void)pthread_mutex_lock(&scr_ffi_lock); }
68
+ static void scr_ffi_lock_leave(void) { (void)pthread_mutex_unlock(&scr_ffi_lock); }
69
+ #endif
70
+
71
+ static void scr_ffi_oom(void) { scr_trap("scriptc: out of memory\n"); }
72
+
73
+ /* Foreign trampolines cannot enter the script exception machinery. Allocation
74
+ * failure and malformed native pointers are fatal process-boundary failures,
75
+ * reported without touching any runtime object. */
76
+ static void scr_ffi_foreign_fatal(const char *message) {
77
+ fputs(message, stderr);
78
+ abort();
79
+ }
80
+
81
+ static void *scr_ffi_foreign_alloc(size_t size) {
82
+ void *ptr = malloc(size == 0 ? 1 : size);
83
+ if (ptr == NULL) scr_ffi_foreign_fatal("scriptc: out of memory\n");
84
+ return ptr;
85
+ }
86
+
87
+ static void scr_ffi_call_free(ScrFfiCall *call) {
88
+ if (call == NULL) return;
89
+ for (size_t i = 0; i < call->nargs; i++) {
90
+ if (call->args[i].kind == SCR_FFI_ARG_DATA) free(call->args[i].value.span.data);
91
+ }
92
+ free(call->args);
93
+ free(call);
94
+ }
95
+
96
+ static bool scr_ffi_table_contains(const ScrFfiTable *table, const ScrClosure *callback) {
97
+ for (size_t i = 0; i < table->len; i++) {
98
+ if (table->entries[i] == callback) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ static void scr_ffi_table_retire(ScrFfiTable *table, ScrClosure *callback) {
104
+ if (table->retired_len == table->retired_cap) {
105
+ size_t cap = table->retired_cap == 0 ? 4 : table->retired_cap * 2;
106
+ if (cap < table->retired_cap || cap > SIZE_MAX / sizeof *table->retired) scr_ffi_oom();
107
+ ScrClosure **retired = realloc(table->retired, cap * sizeof *retired);
108
+ if (retired == NULL) scr_ffi_oom();
109
+ table->retired = retired;
110
+ table->retired_cap = cap;
111
+ }
112
+ table->retired[table->retired_len++] = callback;
113
+ }
114
+
115
+ static void scr_ffi_release_retired(ScrFfiTable *table) {
116
+ ScrClosure **retired = NULL;
117
+ size_t len = 0;
118
+ scr_ffi_lock_enter();
119
+ if (table->queued == 0 && table->retired_len > 0) {
120
+ retired = table->retired;
121
+ len = table->retired_len;
122
+ table->retired = NULL;
123
+ table->retired_len = 0;
124
+ table->retired_cap = 0;
125
+ }
126
+ scr_ffi_lock_leave();
127
+ for (size_t i = 0; i < len; i++) scr_closure_release(retired[i]);
128
+ free(retired);
129
+ }
130
+
131
+ void scr_ffi_retain_foreign(ScrFfiTable *table, ScrClosure *callback) {
132
+ table->teardown = &scr_ffi_teardown_foreign;
133
+ scr_ffi_link(table);
134
+ scr_ffi_lock_enter();
135
+ if (table->len == table->cap) {
136
+ size_t cap = table->cap == 0 ? 4 : table->cap * 2;
137
+ if (cap < table->cap || cap > SIZE_MAX / sizeof *table->entries) scr_ffi_oom();
138
+ ScrClosure **entries = realloc(table->entries, cap * sizeof *entries);
139
+ if (entries == NULL) scr_ffi_oom();
140
+ table->entries = entries;
141
+ table->cap = cap;
142
+ }
143
+ table->entries[table->len++] = scr_closure_retain(callback);
144
+ scr_ffi_foreign_registrations++;
145
+ scr_ffi_lock_leave();
146
+ }
147
+
148
+ void scr_ffi_require_foreign(ScrFfiTable *table, ScrClosure *callback) {
149
+ scr_ffi_lock_enter();
150
+ bool found = scr_ffi_table_contains(table, callback);
151
+ scr_ffi_lock_leave();
152
+ if (!found) scr_trap("scriptc: releasing a native callback registration that does not exist\n");
153
+ }
154
+
155
+ void scr_ffi_release_foreign(ScrFfiTable *table, ScrClosure *callback) {
156
+ scr_ffi_lock_enter();
157
+ for (size_t i = 0; i < table->len; i++) {
158
+ if (table->entries[i] != callback) continue;
159
+ ScrClosure *owned = table->entries[i];
160
+ table->len--;
161
+ if (i != table->len) table->entries[i] = table->entries[table->len];
162
+ if (scr_ffi_foreign_registrations > 0) scr_ffi_foreign_registrations--;
163
+ bool deferred = table->queued > 0;
164
+ if (deferred) scr_ffi_table_retire(table, owned);
165
+ scr_ffi_lock_leave();
166
+ if (!deferred) scr_closure_release(owned);
167
+ return;
168
+ }
169
+ scr_ffi_lock_leave();
170
+ scr_trap("scriptc: releasing a native callback registration that does not exist\n");
171
+ }
172
+
173
+ ScrFfiCall *scr_ffi_call_new(ScrFfiTable *table, ScrClosure *callback,
174
+ ScrFfiDispatchFn dispatch, size_t nargs) {
175
+ ScrFfiCall *call = scr_ffi_foreign_alloc(sizeof *call);
176
+ call->table = table;
177
+ call->loop = table->loop;
178
+ call->callback = callback;
179
+ call->dispatch = dispatch;
180
+ call->nargs = nargs;
181
+ call->args = nargs == 0 ? NULL : scr_ffi_foreign_alloc(nargs * sizeof *call->args);
182
+ if (nargs > 0) memset(call->args, 0, nargs * sizeof *call->args);
183
+ call->next = NULL;
184
+ return call;
185
+ }
186
+
187
+ static ScrFfiArg *scr_ffi_call_arg(ScrFfiCall *call, size_t index) {
188
+ if (index >= call->nargs) scr_ffi_foreign_fatal("scriptc: invalid staged FFI callback argument\n");
189
+ return &call->args[index];
190
+ }
191
+
192
+ void scr_ffi_call_set_f64(ScrFfiCall *call, size_t i, double v) {
193
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i); arg->kind = SCR_FFI_ARG_F64; arg->value.f64 = v;
194
+ }
195
+ void scr_ffi_call_set_bool(ScrFfiCall *call, size_t i, uint8_t v) {
196
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i); arg->kind = SCR_FFI_ARG_BOOL; arg->value.u32 = v != 0;
197
+ }
198
+ void scr_ffi_call_set_u8(ScrFfiCall *call, size_t i, uint8_t v) {
199
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i); arg->kind = SCR_FFI_ARG_U8; arg->value.u32 = v;
200
+ }
201
+ void scr_ffi_call_set_u32(ScrFfiCall *call, size_t i, uint32_t v) {
202
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i); arg->kind = SCR_FFI_ARG_U32; arg->value.u32 = v;
203
+ }
204
+ void scr_ffi_call_set_i32(ScrFfiCall *call, size_t i, int32_t v) {
205
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i); arg->kind = SCR_FFI_ARG_I32; arg->value.i32 = v;
206
+ }
207
+
208
+ static void scr_ffi_call_copy_data(ScrFfiCall *call, size_t i,
209
+ const uint8_t *v, size_t len,
210
+ const char *null_message) {
211
+ if (v == NULL && len != 0) scr_ffi_foreign_fatal(null_message);
212
+ ScrFfiArg *arg = scr_ffi_call_arg(call, i);
213
+ arg->kind = SCR_FFI_ARG_DATA;
214
+ arg->value.span.len = len;
215
+ arg->value.span.data = len == 0 ? NULL : scr_ffi_foreign_alloc(len);
216
+ if (len > 0) memcpy(arg->value.span.data, v, len);
217
+ }
218
+
219
+ void scr_ffi_call_copy_string(ScrFfiCall *call, size_t i,
220
+ const uint8_t *v, size_t len) {
221
+ scr_ffi_call_copy_data(call, i, v, len,
222
+ "scriptc: native callback passed a NULL string span with nonzero length\n");
223
+ }
224
+
225
+ void scr_ffi_call_copy_bytes(ScrFfiCall *call, size_t i,
226
+ const uint8_t *v, size_t len) {
227
+ scr_ffi_call_copy_data(call, i, v, len,
228
+ "scriptc: native callback passed a NULL bytes span with nonzero length\n");
229
+ }
230
+
231
+ void scr_ffi_call_copy_cstring(ScrFfiCall *call, size_t i, const char *v) {
232
+ if (v == NULL) scr_ffi_foreign_fatal("scriptc: native callback passed a NULL cstring\n");
233
+ scr_ffi_call_copy_data(call, i, (const uint8_t *)v, strlen(v),
234
+ "scriptc: native callback passed a NULL cstring\n");
235
+ }
236
+
237
+ void scr_ffi_post(ScrFfiCall *call) {
238
+ scr_ffi_lock_enter();
239
+ if (scr_ffi_stopping || !scr_ffi_table_contains(call->table, call->callback)) {
240
+ scr_ffi_lock_leave();
241
+ scr_ffi_call_free(call);
242
+ return;
243
+ }
244
+ call->table->queued++;
245
+ if (scr_ffi_tail != NULL) scr_ffi_tail->next = call;
246
+ else scr_ffi_head = call;
247
+ scr_ffi_tail = call;
248
+ #if !defined(_WIN32) && !defined(__wasi__)
249
+ if (scr_ffi_wake_pipe[1] >= 0) {
250
+ ssize_t ignored = write(scr_ffi_wake_pipe[1], "f", 1);
251
+ (void)ignored; /* a full pipe is already readable */
252
+ }
253
+ #endif
254
+ scr_ffi_lock_leave();
255
+ }
256
+
257
+ static const ScrFfiArg *scr_ffi_get_arg(const ScrFfiCall *call, size_t index) {
258
+ if (index >= call->nargs) scr_trap("scriptc: invalid staged FFI callback argument\n");
259
+ return &call->args[index];
260
+ }
261
+
262
+ double scr_ffi_call_get_f64(const ScrFfiCall *call, size_t i) { return scr_ffi_get_arg(call, i)->value.f64; }
263
+ bool scr_ffi_call_get_bool(const ScrFfiCall *call, size_t i) { return scr_ffi_get_arg(call, i)->value.u32 != 0; }
264
+ double scr_ffi_call_get_u8(const ScrFfiCall *call, size_t i) { return (double)scr_ffi_get_arg(call, i)->value.u32; }
265
+ double scr_ffi_call_get_u32(const ScrFfiCall *call, size_t i) { return (double)scr_ffi_get_arg(call, i)->value.u32; }
266
+ double scr_ffi_call_get_i32(const ScrFfiCall *call, size_t i) { return (double)scr_ffi_get_arg(call, i)->value.i32; }
267
+ const uint8_t *scr_ffi_call_get_data(const ScrFfiCall *call, size_t i) { return scr_ffi_get_arg(call, i)->value.span.data; }
268
+ size_t scr_ffi_call_get_len(const ScrFfiCall *call, size_t i) { return scr_ffi_get_arg(call, i)->value.span.len; }
269
+
270
+ static bool scr_ffi_pending(void) {
271
+ bool pending;
272
+ scr_ffi_lock_enter();
273
+ pending = scr_ffi_foreign_registrations > 0 || scr_ffi_head != NULL;
274
+ scr_ffi_lock_leave();
275
+ return pending;
276
+ }
277
+
278
+ static void scr_ffi_wake_drain(void) {
279
+ #if !defined(_WIN32) && !defined(__wasi__)
280
+ if (scr_ffi_wake_pipe[0] < 0) return;
281
+ char buf[64];
282
+ while (read(scr_ffi_wake_pipe[0], buf, sizeof buf) > 0) {}
283
+ #endif
284
+ }
285
+
286
+ static bool scr_ffi_dispatch(void) {
287
+ scr_ffi_lock_enter();
288
+ ScrFfiCall *call = scr_ffi_head;
289
+ if (call != NULL) {
290
+ scr_ffi_head = call->next;
291
+ if (scr_ffi_head == NULL) scr_ffi_tail = NULL;
292
+ }
293
+ scr_ffi_lock_leave();
294
+ if (call == NULL) return false;
295
+
296
+ call->dispatch(call->callback, call);
297
+
298
+ ScrFfiTable *table = call->table;
299
+ scr_ffi_lock_enter();
300
+ if (table->queued > 0) table->queued--;
301
+ /* Keep the wake fd readable while queued calls remain. Drain only while
302
+ * holding the same lock writers use, closing the post-vs-drain lost-wakeup
303
+ * race for the transition to an empty queue. */
304
+ bool more = scr_ffi_head != NULL;
305
+ if (!more) scr_ffi_wake_drain();
306
+ scr_ffi_lock_leave();
307
+ scr_ffi_call_free(call);
308
+ scr_ffi_release_retired(table);
309
+ return true;
310
+ }
311
+
312
+ static int scr_ffi_pollfd(void) { return scr_ffi_wake_pipe[0]; }
313
+
314
+ void scr_ffi_install(void) {
315
+ if (scr_ffi_installed) return;
316
+ scr_ffi_installed = true;
317
+ #if !defined(_WIN32) && !defined(__wasi__)
318
+ if (pipe(scr_ffi_wake_pipe) == 0) {
319
+ for (int i = 0; i < 2; i++) {
320
+ (void)fcntl(scr_ffi_wake_pipe[i], F_SETFL, O_NONBLOCK);
321
+ (void)fcntl(scr_ffi_wake_pipe[i], F_SETFD, FD_CLOEXEC);
322
+ }
323
+ } else {
324
+ scr_ffi_wake_pipe[0] = scr_ffi_wake_pipe[1] = -1;
325
+ }
326
+ #endif
327
+ scr_loop_set_ffi(&scr_ffi_pending, &scr_ffi_dispatch, &scr_ffi_pollfd,
328
+ &scr_ffi_stop);
329
+ }
330
+
331
+ void scr_ffi_stop(void) {
332
+ scr_ffi_lock_enter();
333
+ if (scr_ffi_stopping) {
334
+ scr_ffi_lock_leave();
335
+ return;
336
+ }
337
+ scr_ffi_stopping = true;
338
+ ScrFfiCall *calls = scr_ffi_head;
339
+ scr_ffi_head = scr_ffi_tail = NULL;
340
+ for (ScrFfiCall *call = calls; call != NULL; call = call->next) {
341
+ if (call->table->queued > 0) call->table->queued--;
342
+ }
343
+ scr_ffi_wake_drain();
344
+ #if !defined(_WIN32) && !defined(__wasi__)
345
+ if (scr_ffi_wake_pipe[0] >= 0) close(scr_ffi_wake_pipe[0]);
346
+ if (scr_ffi_wake_pipe[1] >= 0) close(scr_ffi_wake_pipe[1]);
347
+ #endif
348
+ scr_ffi_wake_pipe[0] = scr_ffi_wake_pipe[1] = -1;
349
+ scr_ffi_lock_leave();
350
+ while (calls != NULL) {
351
+ ScrFfiCall *next = calls->next;
352
+ ScrFfiTable *table = calls->table;
353
+ scr_ffi_call_free(calls);
354
+ scr_ffi_release_retired(table);
355
+ calls = next;
356
+ }
357
+ }
358
+
359
+ void scr_ffi_teardown_foreign(ScrFfiTable *table) {
360
+ scr_ffi_stop();
361
+ scr_ffi_lock_enter();
362
+ ScrClosure **entries = table->entries;
363
+ size_t len = table->len;
364
+ ScrClosure **retired = table->retired;
365
+ size_t retired_len = table->retired_len;
366
+ if (scr_ffi_foreign_registrations >= len) scr_ffi_foreign_registrations -= len;
367
+ table->entries = NULL;
368
+ table->len = 0;
369
+ table->cap = 0;
370
+ table->retired = NULL;
371
+ table->retired_len = 0;
372
+ table->retired_cap = 0;
373
+ table->queued = 0;
374
+ scr_ffi_lock_leave();
375
+ for (size_t i = 0; i < len; i++) scr_closure_release(entries[i]);
376
+ free(entries);
377
+ for (size_t i = 0; i < retired_len; i++) scr_closure_release(retired[i]);
378
+ free(retired);
379
+ }
package/src/scr_runtime.h CHANGED
@@ -389,6 +389,9 @@ typedef struct ScrStr {
389
389
  } ScrStr;
390
390
 
391
391
  ScrStr *scr_str_new(const char *bytes, size_t len); /* returns +1 */
392
+ /* Native callback boundary: copy and WHATWG-decode a UTF-8 span, replacing
393
+ * malformed subsequences with U+FFFD. NULL with len == 0 is an empty span. */
394
+ ScrStr *scr_str_from_utf8_lossy(const uint8_t *bytes, size_t len); /* +1 */
392
395
 
393
396
  /* Internal allocators for buffer builders (scr_json.c): a +1 string with
394
397
  * UNINITIALIZED data (the builder fills bytes, then len and the NUL), and
@@ -1316,6 +1319,100 @@ static inline ScrClosure *scr_closure_retain(ScrClosure *c) {
1316
1319
 
1317
1320
  void scr_closure_release(ScrClosure *c); /* releases the boxes; NULL-tolerant */
1318
1321
 
1322
+ /* ── outbound FFI retained callbacks (scr_ffi.c) ─────────────────────
1323
+ * One compiler-emitted table per retained callback descriptor. For
1324
+ * context-bearing descriptors, entries are counted rather than
1325
+ * deduplicated: registering the same closure twice requires two matching
1326
+ * releases. Raw singleton slots replace instead: commit_slot retires
1327
+ * every pin the new registration superseded — a duplicate of the same
1328
+ * closure included — so after any number of set calls exactly one
1329
+ * release is pending. The table owns one closure reference per entry and
1330
+ * joins a process-global teardown list on first use. Script-thread retained
1331
+ * callbacks do not contribute event-loop liveness; foreign registrations do,
1332
+ * and their queued invocations keep released closure pins alive until the
1333
+ * script-thread dispatch has finished. */
1334
+ typedef struct ScrFfiTable {
1335
+ ScrClosure **entries;
1336
+ size_t len;
1337
+ size_t cap;
1338
+ struct ScrFfiTable *next;
1339
+ bool linked;
1340
+ /* Raw retained singletons only: the compiler-emitted global the
1341
+ * trampoline dispatches through. Teardown and release disarm it so a
1342
+ * late native invocation hits the trampoline's NULL trap instead of a
1343
+ * freed closure. NULL for context-bearing descriptors. */
1344
+ ScrClosure **slot;
1345
+ /* Foreign descriptors only. The global FFI queue lock protects these
1346
+ * fields together with entries/len/cap. Retired pins were explicitly
1347
+ * released while a queued invocation could still name them; dispatch
1348
+ * drops them on the script thread after this table's queue reaches zero. */
1349
+ ScrClosure **retired;
1350
+ size_t retired_len;
1351
+ size_t retired_cap;
1352
+ size_t queued;
1353
+ /* Reserved process-loop identity captured by queued calls. Executables use
1354
+ * one global loop today; library mode can make this per instance later
1355
+ * without changing the post/call ABI. */
1356
+ void *loop;
1357
+ /* Optional format-5 teardown. NULL keeps format-4 tables on the compact
1358
+ * always-linked path; foreign tables point into scr_ffi_queue.c. */
1359
+ void (*teardown)(struct ScrFfiTable *table);
1360
+ } ScrFfiTable;
1361
+
1362
+ void scr_ffi_link(ScrFfiTable *table);
1363
+ void scr_ffi_retain(ScrFfiTable *table, ScrClosure *callback);
1364
+ void scr_ffi_retain_foreign(ScrFfiTable *table, ScrClosure *callback);
1365
+ /* Raw singleton registration, split around the native set call:
1366
+ * retain_slot pins the incoming closure BEFORE the call without touching
1367
+ * the current registration; commit_slot repoints the slot and retires the
1368
+ * superseded pins after the call returns. */
1369
+ void scr_ffi_retain_slot(ScrFfiTable *table, ScrClosure **slot, ScrClosure *callback);
1370
+ void scr_ffi_commit_slot(ScrFfiTable *table, ScrClosure *callback);
1371
+ /* Traps unless the registration exists — emitted BEFORE a native release
1372
+ * call so a bogus release cannot reach native code. */
1373
+ void scr_ffi_require(ScrFfiTable *table, ScrClosure *callback);
1374
+ void scr_ffi_release(ScrFfiTable *table, ScrClosure *callback);
1375
+ void scr_ffi_require_foreign(ScrFfiTable *table, ScrClosure *callback);
1376
+ void scr_ffi_release_foreign(ScrFfiTable *table, ScrClosure *callback);
1377
+ void scr_ffi_teardown(ScrFfiTable *table);
1378
+ void scr_ffi_teardown_all(void);
1379
+
1380
+ /* Format-5 foreign-thread delivery. A generated native trampoline creates a
1381
+ * plain malloc-backed call, stages scalar values or copied byte spans, and
1382
+ * posts it. It never touches closure RC, exception cells, fibers, or other
1383
+ * script-thread-only runtime state. The generated dispatch thunk runs later
1384
+ * on the event loop and materializes script strings/bytes there. */
1385
+ typedef struct ScrFfiCall ScrFfiCall;
1386
+ typedef void (*ScrFfiDispatchFn)(ScrClosure *callback, ScrFfiCall *call);
1387
+
1388
+ ScrFfiCall *scr_ffi_call_new(ScrFfiTable *table, ScrClosure *callback,
1389
+ ScrFfiDispatchFn dispatch, size_t nargs);
1390
+ void scr_ffi_call_set_f64(ScrFfiCall *call, size_t index, double value);
1391
+ void scr_ffi_call_set_bool(ScrFfiCall *call, size_t index, uint8_t value);
1392
+ void scr_ffi_call_set_u8(ScrFfiCall *call, size_t index, uint8_t value);
1393
+ void scr_ffi_call_set_u32(ScrFfiCall *call, size_t index, uint32_t value);
1394
+ void scr_ffi_call_set_i32(ScrFfiCall *call, size_t index, int32_t value);
1395
+ void scr_ffi_call_copy_cstring(ScrFfiCall *call, size_t index, const char *value);
1396
+ void scr_ffi_call_copy_string(ScrFfiCall *call, size_t index,
1397
+ const uint8_t *value, size_t len);
1398
+ void scr_ffi_call_copy_bytes(ScrFfiCall *call, size_t index,
1399
+ const uint8_t *value, size_t len);
1400
+ void scr_ffi_post(ScrFfiCall *call);
1401
+
1402
+ double scr_ffi_call_get_f64(const ScrFfiCall *call, size_t index);
1403
+ bool scr_ffi_call_get_bool(const ScrFfiCall *call, size_t index);
1404
+ double scr_ffi_call_get_u8(const ScrFfiCall *call, size_t index);
1405
+ double scr_ffi_call_get_u32(const ScrFfiCall *call, size_t index);
1406
+ double scr_ffi_call_get_i32(const ScrFfiCall *call, size_t index);
1407
+ const uint8_t *scr_ffi_call_get_data(const ScrFfiCall *call, size_t index);
1408
+ size_t scr_ffi_call_get_len(const ScrFfiCall *call, size_t index);
1409
+
1410
+ void scr_ffi_install(void);
1411
+ void scr_ffi_stop(void);
1412
+ void scr_ffi_teardown_foreign(ScrFfiTable *table);
1413
+ void scr_loop_set_ffi(bool (*pending)(void), bool (*dispatch)(void),
1414
+ int (*pollfd)(void), void (*stop)(void));
1415
+
1319
1416
  /* ── unions ─────────────────────────────────────────────────────────
1320
1417
  * A union value (`A | B`) is an IMMUTABLE tagged box: a refcounted header,
1321
1418
  * the arm's tag (its index in the compiler's canonical arm order), and one
@@ -4735,6 +4832,9 @@ ScrJsval *scr_jsval_from_bytes(const ScrBytes *b);
4735
4832
  * THROWS Node's "Invalid typed array length" RangeError catchably and
4736
4833
  * returns NULL with the exception pending). */
4737
4834
  ScrBytes *scr_bytes_new(ScrBytesElem elem, double n); /* +1 */
4835
+ /* Native callback boundary: exact owned u8 copy. NULL with len == 0 is an
4836
+ * empty span. */
4837
+ ScrBytes *scr_bytes_from_data(const uint8_t *data, size_t len); /* +1 */
4738
4838
 
4739
4839
  /* `new Uint8Array(src)` / Buffer.from(u8): a same-elem-kind copy (the
4740
4840
  * compiler fences cross-kind construction). Borrows src. Never throws. */