@scriptc/compiler 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.
Files changed (51) hide show
  1. package/dist/backend/cc-cache.test.js +308 -1
  2. package/dist/backend/cc-cache.test.js.map +1 -1
  3. package/dist/backend/cc.d.ts +6 -0
  4. package/dist/backend/cc.js +707 -68
  5. package/dist/backend/cc.js.map +1 -1
  6. package/dist/backend/emission/emit-exprs.js +53 -2
  7. package/dist/backend/emission/emit-exprs.js.map +1 -1
  8. package/dist/backend/emission/emitter.d.ts +12 -5
  9. package/dist/backend/emission/emitter.js +160 -22
  10. package/dist/backend/emission/emitter.js.map +1 -1
  11. package/dist/backend/emission/may-throw.js +7 -4
  12. package/dist/backend/emission/may-throw.js.map +1 -1
  13. package/dist/backend/ffi-callbacks.d.ts +38 -1
  14. package/dist/backend/ffi-callbacks.js +76 -3
  15. package/dist/backend/ffi-callbacks.js.map +1 -1
  16. package/dist/backend/llvm/emitter.js +243 -12
  17. package/dist/backend/llvm/emitter.js.map +1 -1
  18. package/dist/diagnostics/diagnostic.js +2 -0
  19. package/dist/diagnostics/diagnostic.js.map +1 -1
  20. package/dist/ffi/profile.d.ts +17 -5
  21. package/dist/ffi/profile.js +169 -22
  22. package/dist/ffi/profile.js.map +1 -1
  23. package/dist/frontend/lowering/lower-calls.d.ts +1 -0
  24. package/dist/frontend/lowering/lower-calls.js +89 -23
  25. package/dist/frontend/lowering/lower-calls.js.map +1 -1
  26. package/dist/frontend/lowering/lower-containers.js +65 -19
  27. package/dist/frontend/lowering/lower-containers.js.map +1 -1
  28. package/dist/frontend/lowering/lowerer.d.ts +12 -0
  29. package/dist/frontend/lowering/lowerer.js +20 -1
  30. package/dist/frontend/lowering/lowerer.js.map +1 -1
  31. package/dist/frontend/ts7/checker.d.ts +2 -0
  32. package/dist/frontend/ts7/checker.js +20 -3
  33. package/dist/frontend/ts7/checker.js.map +1 -1
  34. package/dist/frontend/types.d.ts +8 -0
  35. package/dist/frontend/types.js +33 -5
  36. package/dist/frontend/types.js.map +1 -1
  37. package/dist/index.js +35 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/ir/nodes.d.ts +22 -9
  40. package/dist/ir/nodes.js +7 -1
  41. package/dist/ir/nodes.js.map +1 -1
  42. package/dist/ir/serialize.d.ts +1 -1
  43. package/dist/ir/serialize.js +1 -1
  44. package/dist/ir/validate.js +74 -1
  45. package/dist/ir/validate.js.map +1 -1
  46. package/dist/library/int-infer.test.js +4 -4
  47. package/dist/library/profile.d.ts +3 -0
  48. package/dist/library/profile.js +9 -1
  49. package/dist/library/profile.js.map +1 -1
  50. package/package.json +2 -2
  51. package/surface-manifest.json +1 -1
@@ -61,9 +61,9 @@
61
61
  * lazy-inflate representation as the C debugging backend.
62
62
  */
63
63
  import { deflateRawSync } from "node:zlib";
64
- import { canMarshalFuncIntoIsland, CAUGHT, DYN, F64, ffiCallbackType, islandCallbackRet, islandPromisePayloadTag, isFfiCallbackParam, isFfiContextParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesDynInvoke, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, NPM_COMPRESS_MIN, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, typeEquals, typeKey, VOID } from "../../ir/nodes.js";
64
+ import { canMarshalFuncIntoIsland, CAUGHT, DYN, F64, ffiCallbackType, islandCallbackRet, islandPromisePayloadTag, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesDynInvoke, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, NPM_COMPRESS_MIN, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, STRING, typeEquals, typeKey, VOID } from "../../ir/nodes.js";
65
65
  import { matchIntegerBytesForLoop } from "../../ir/integer-loops.js";
66
- import { allocateFfiCallbackAdapters } from "../ffi-callbacks.js";
66
+ import { allocateFfiCallbackAdapters, collectFfiRetainedOps, hasForeignFfiCallback, hasRetainedFfiCallback, parseFfiCallbackKey } from "../ffi-callbacks.js";
67
67
  import { computeMayThrow } from "../emission/may-throw.js";
68
68
  import { mangleArgPack, mangleAsyncSpawn, mangleClassNew, mangleClassObj, mangleClassRetain, mangleFnClosure, mangleFunction, mangleGenDrop, mangleGenResThunk, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordNew, mangleRecordStruct, mangleResolveThunk, mangleTrampoline, mangleVtStruct, mangleWrapper } from "../mangle.js";
69
69
  import { BlockBuilder } from "./blocks.js";
@@ -81,6 +81,8 @@ function ffiNativeTypeLl(cls) {
81
81
  case "u32":
82
82
  case "i32":
83
83
  return "i32";
84
+ case "cstring":
85
+ return "ptr";
84
86
  case "string":
85
87
  case "bytes":
86
88
  throw new Error(`llvm emitter bug: span class '${cls}' has no scalar LLVM type`);
@@ -917,6 +919,12 @@ class LlEmitter {
917
919
  /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their
918
920
  * distinct call-scoped TLS closure slots. */
919
921
  ffiCallbackAdapters;
922
+ /** Module-level constant consulted per ffiCall: with a retained
923
+ * descriptor anywhere in the manifest, every native call is a
924
+ * pending-exception checkpoint (may-throw derives the same fact from
925
+ * the same helper). */
926
+ ffiHasRetainedCallback;
927
+ ffiHasForeignCallback;
920
928
  globalTypes = new Map();
921
929
  /** May-throw analysis (the C emitter's computeMayThrow, shared): pending
922
930
  * checks are emitted only after calls that can actually raise. */
@@ -1019,6 +1027,8 @@ class LlEmitter {
1019
1027
  // bytes behind a wasm32 object and 16 bytes behind a 64-bit object.
1020
1028
  this.cycleColorOffset = options.pointerBits === 32 ? 12 : 16;
1021
1029
  this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []);
1030
+ this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
1031
+ this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
1022
1032
  for (const fn of mod.functions)
1023
1033
  this.fnByName.set(fn.name, fn);
1024
1034
  for (const entry of mod.ffiImports ?? []) {
@@ -1172,13 +1182,129 @@ class LlEmitter {
1172
1182
  this.declare(`declare zeroext i1 @scr_exc_pending()`);
1173
1183
  this.declare(`declare void @scr_trap(ptr)`);
1174
1184
  const expired = this.cstr("scriptc: native callback invoked outside its call-scoped lifetime\n");
1185
+ const released = this.cstr("scriptc: native callback invoked outside its retained lifetime\n");
1175
1186
  for (const adapter of this.ffiCallbackAdapters.values()) {
1176
1187
  const cb = adapter.callback;
1177
1188
  if (adapter.tls !== null)
1178
1189
  globals.push(`@${adapter.tls} = internal thread_local global ptr null`);
1179
- const params = cb.params.map((param, i) => isFfiContextParam(param) ? `ptr %ctx` : `${ffiNativeTypeLl(param)} %a${i}`);
1190
+ if (adapter.global !== null)
1191
+ globals.push(`@${adapter.global} = internal global ptr null`);
1192
+ if (adapter.table !== null) {
1193
+ globals.push(`@${adapter.table} = internal global %ScrFfiTable zeroinitializer`);
1194
+ }
1195
+ const params = cb.params.flatMap((param, i) => {
1196
+ if (isFfiContextParam(param))
1197
+ return [`ptr %ctx`];
1198
+ if (param === "string" || param === "bytes") {
1199
+ return [`ptr %a${i}`, `${this.sizeType} %a${i}_len`];
1200
+ }
1201
+ return [`${ffiNativeTypeLl(param)} %a${i}`];
1202
+ });
1180
1203
  const ret = ffiNativeTypeLl(cb.returns);
1181
- defs.push(`define internal ${ret} @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, `entry:`, adapter.tls === null ? ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0` : ` %cb = load ptr, ptr @${adapter.tls}`, ` %missing = icmp eq ptr %cb, null`, ` br i1 %missing, label %expired, label %ready`, `expired:`, ` call void @scr_trap(ptr ${expired})`, ` unreachable`, `ready:`, ` %pending = call zeroext i1 @scr_exc_pending()`, ` br i1 %pending, label %skip, label %invoke`, `skip:`, ` ret ${ffiCallbackDummyLl(cb)}`, `invoke:`, ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, ` %fn = load ptr, ptr %fnp`);
1204
+ if (cb.invoke === "foreign") {
1205
+ if (adapter.table === null || !cb.params.some(isFfiContextParam) || cb.returns !== "void") {
1206
+ throw new Error("llvm emitter bug: invalid foreign FFI callback descriptor");
1207
+ }
1208
+ const dispatch = `${adapter.symbol}_dispatch`;
1209
+ const scriptArgs = [];
1210
+ const dispatchBody = [
1211
+ `define internal void @${dispatch}(ptr %cb, ptr %call) ${FN_ATTRS} {`,
1212
+ `entry:`,
1213
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
1214
+ ` %fn = load ptr, ptr %fnp`,
1215
+ ];
1216
+ for (let i = 0; i < cb.params.length; i++) {
1217
+ const param = cb.params[i];
1218
+ if (isFfiContextParam(param))
1219
+ continue;
1220
+ switch (param) {
1221
+ case "f64":
1222
+ this.declare(`declare double @scr_ffi_call_get_f64(ptr, ${this.sizeType})`);
1223
+ dispatchBody.push(` %s${i} = call double @scr_ffi_call_get_f64(ptr %call, ${this.sizeType} ${i})`);
1224
+ scriptArgs.push(`double %s${i}`);
1225
+ break;
1226
+ case "bool":
1227
+ this.declare(`declare zeroext i1 @scr_ffi_call_get_bool(ptr, ${this.sizeType})`);
1228
+ dispatchBody.push(` %s${i} = call zeroext i1 @scr_ffi_call_get_bool(ptr %call, ${this.sizeType} ${i})`);
1229
+ scriptArgs.push(`i1 %s${i}`);
1230
+ break;
1231
+ case "u8":
1232
+ case "u32":
1233
+ case "i32":
1234
+ this.declare(`declare double @scr_ffi_call_get_${param}(ptr, ${this.sizeType})`);
1235
+ dispatchBody.push(` %s${i} = call double @scr_ffi_call_get_${param}(ptr %call, ${this.sizeType} ${i})`);
1236
+ scriptArgs.push(`double %s${i}`);
1237
+ break;
1238
+ case "cstring":
1239
+ case "string":
1240
+ case "bytes": {
1241
+ this.declare(`declare ptr @scr_ffi_call_get_data(ptr, ${this.sizeType})`);
1242
+ this.declare(`declare ${this.sizeType} @scr_ffi_call_get_len(ptr, ${this.sizeType})`);
1243
+ dispatchBody.push(` %data${i} = call ptr @scr_ffi_call_get_data(ptr %call, ${this.sizeType} ${i})`, ` %len${i} = call ${this.sizeType} @scr_ffi_call_get_len(ptr %call, ${this.sizeType} ${i})`);
1244
+ if (param === "bytes") {
1245
+ this.declare(`declare ptr @scr_bytes_from_data(ptr, ${this.sizeType})`);
1246
+ dispatchBody.push(` %s${i} = call ptr @scr_bytes_from_data(ptr %data${i}, ${this.sizeType} %len${i})`);
1247
+ }
1248
+ else {
1249
+ this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`);
1250
+ dispatchBody.push(` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %data${i}, ${this.sizeType} %len${i})`);
1251
+ }
1252
+ scriptArgs.push(`ptr %s${i}`);
1253
+ break;
1254
+ }
1255
+ }
1256
+ }
1257
+ dispatchBody.push(` call void %fn(${[`ptr %cb`, ...scriptArgs].join(", ")})`, ` ret void`, `}`, ``);
1258
+ defs.push(...dispatchBody);
1259
+ this.declare(`declare ptr @scr_ffi_call_new(ptr, ptr, ptr, ${this.sizeType})`);
1260
+ this.declare(`declare void @scr_ffi_post(ptr)`);
1261
+ defs.push(`define internal void @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, `entry:`, ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, ` %call = call ptr @scr_ffi_call_new(ptr @${adapter.table}, ptr %cb, ptr @${dispatch}, ${this.sizeType} ${cb.params.length})`);
1262
+ for (let i = 0; i < cb.params.length; i++) {
1263
+ const param = cb.params[i];
1264
+ if (isFfiContextParam(param))
1265
+ continue;
1266
+ if (param === "cstring") {
1267
+ this.declare(`declare void @scr_ffi_call_copy_cstring(ptr, ${this.sizeType}, ptr)`);
1268
+ defs.push(` call void @scr_ffi_call_copy_cstring(ptr %call, ${this.sizeType} ${i}, ptr %a${i})`);
1269
+ }
1270
+ else if (param === "string" || param === "bytes") {
1271
+ this.declare(`declare void @scr_ffi_call_copy_${param}(ptr, ${this.sizeType}, ptr, ${this.sizeType})`);
1272
+ defs.push(` call void @scr_ffi_call_copy_${param}(ptr %call, ${this.sizeType} ${i}, ptr %a${i}, ${this.sizeType} %a${i}_len)`);
1273
+ }
1274
+ else {
1275
+ const nativeTy = ffiNativeTypeLl(param);
1276
+ this.declare(`declare void @scr_ffi_call_set_${param}(ptr, ${this.sizeType}, ${nativeTy})`);
1277
+ defs.push(` call void @scr_ffi_call_set_${param}(ptr %call, ${this.sizeType} ${i}, ${nativeTy} %a${i})`);
1278
+ }
1279
+ }
1280
+ defs.push(` call void @scr_ffi_post(ptr %call)`, ` ret void`, `}`, ``);
1281
+ continue;
1282
+ }
1283
+ defs.push(`define internal ${ret} @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, `entry:`, adapter.tls !== null
1284
+ ? ` %cb = load ptr, ptr @${adapter.tls}`
1285
+ : adapter.global !== null
1286
+ ? ` %cb = load ptr, ptr @${adapter.global}`
1287
+ : ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, ` %missing = icmp eq ptr %cb, null`, ` br i1 %missing, label %expired, label %ready`, `expired:`, ` call void @scr_trap(ptr ${adapter.callback.lifetime === "call" ? expired : released})`, ` unreachable`, `ready:`, ` %pending = call zeroext i1 @scr_exc_pending()`, ` br i1 %pending, label %skip, label %invoke`, `skip:`, ` ret ${ffiCallbackDummyLl(cb)}`, `invoke:`);
1288
+ // Validate every native pointer before allocating any copy-in value.
1289
+ // A later bad slot therefore cannot leak an earlier materialization.
1290
+ for (let i = 0; i < cb.params.length; i++) {
1291
+ const param = cb.params[i];
1292
+ if (param !== "cstring" && param !== "string" && param !== "bytes")
1293
+ continue;
1294
+ const invalid = `%invalid${i}`;
1295
+ defs.push(` %null${i} = icmp eq ptr %a${i}, null`);
1296
+ if (param === "cstring") {
1297
+ defs.push(` ${invalid} = or i1 %null${i}, false`);
1298
+ }
1299
+ else {
1300
+ defs.push(` %nonempty${i} = icmp ne ${this.sizeType} %a${i}_len, 0`, ` ${invalid} = and i1 %null${i}, %nonempty${i}`);
1301
+ }
1302
+ const message = param === "cstring"
1303
+ ? "scriptc: native callback passed a NULL cstring\n"
1304
+ : `scriptc: native callback passed a NULL ${param} span with nonzero length\n`;
1305
+ defs.push(` br i1 ${invalid}, label %invalid_param${i}, label %param_ok${i}`, `invalid_param${i}:`, ` call void @scr_trap(ptr ${this.cstr(message)})`, ` unreachable`, `param_ok${i}:`);
1306
+ }
1307
+ defs.push(` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, ` %fn = load ptr, ptr %fnp`);
1182
1308
  const scriptArgs = [];
1183
1309
  for (let i = 0; i < cb.params.length; i++) {
1184
1310
  const param = cb.params[i];
@@ -1204,16 +1330,40 @@ class LlEmitter {
1204
1330
  defs.push(` %s${i} = sitofp i32 %a${i} to double`);
1205
1331
  scriptArgs.push(`double %s${i}`);
1206
1332
  break;
1333
+ case "cstring":
1334
+ this.declare(`declare ${this.sizeType} @strlen(ptr)`);
1335
+ this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`);
1336
+ defs.push(` %len${i} = call ${this.sizeType} @strlen(ptr %a${i})`, ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${this.sizeType} %len${i})`);
1337
+ scriptArgs.push(`ptr %s${i}`);
1338
+ break;
1339
+ case "string":
1340
+ this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`);
1341
+ defs.push(` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${this.sizeType} %a${i}_len)`);
1342
+ scriptArgs.push(`ptr %s${i}`);
1343
+ break;
1344
+ case "bytes":
1345
+ this.declare(`declare ptr @scr_bytes_from_data(ptr, ${this.sizeType})`);
1346
+ defs.push(` %s${i} = call ptr @scr_bytes_from_data(ptr %a${i}, ${this.sizeType} %a${i}_len)`);
1347
+ scriptArgs.push(`ptr %s${i}`);
1348
+ break;
1207
1349
  }
1208
1350
  }
1209
1351
  const ft = ffiCallbackType(cb);
1210
1352
  const internalRet = this.llType(ft.ret);
1211
1353
  const callArgs = [`ptr %cb`, ...scriptArgs].join(", ");
1354
+ if (cb.lifetime === "retained") {
1355
+ this.declare(`declare ptr @scr_closure_retain_v(ptr)`);
1356
+ this.declare(`declare void @scr_closure_release_v(ptr)`);
1357
+ defs.push(` %invoke_pin = call ptr @scr_closure_retain_v(ptr %cb)`);
1358
+ }
1212
1359
  if (cb.returns === "void") {
1213
- defs.push(` call void %fn(${callArgs})`, ` ret void`, `}`, ``);
1360
+ defs.push(` call void %fn(${callArgs})`, ...(cb.lifetime === "retained" ? [` call void @scr_closure_release_v(ptr %invoke_pin)`] : []), ` ret void`, `}`, ``);
1214
1361
  continue;
1215
1362
  }
1216
1363
  defs.push(` %result = call ${internalRet} %fn(${callArgs})`);
1364
+ if (cb.lifetime === "retained") {
1365
+ defs.push(` call void @scr_closure_release_v(ptr %invoke_pin)`);
1366
+ }
1217
1367
  switch (cb.returns) {
1218
1368
  case "f64":
1219
1369
  defs.push(` ret double %result`);
@@ -1337,7 +1487,6 @@ class LlEmitter {
1337
1487
  const snapshotsTlsCa = moduleUsesTls(this.mod) || moduleUsesTlsCa(this.mod) ||
1338
1488
  moduleEmbedsBuiltin(this.mod, "node:https") ||
1339
1489
  moduleEmbedsBuiltin(this.mod, "node:tls");
1340
- const hasRefGlobals = globals.some((g) => isRefCounted(g.type)) || fnValueProps.length > 0;
1341
1490
  // The process verdict has the same precedence as the C reference
1342
1491
  // emitter: node:test owns the final status when present; otherwise an
1343
1492
  // embedded process.exitCode owns it; ordinary programs return zero.
@@ -1348,6 +1497,8 @@ class LlEmitter {
1348
1497
  this.declare(`declare void @scr_events_install()`);
1349
1498
  if (usesFsWatch)
1350
1499
  this.declare(`declare void @scr_watch_install()`);
1500
+ if (this.ffiHasForeignCallback)
1501
+ this.declare(`declare void @scr_ffi_install()`);
1351
1502
  if (usesStream)
1352
1503
  this.declare(`declare void @scr_stream_install()`);
1353
1504
  if (usesNet) {
@@ -1375,12 +1526,21 @@ class LlEmitter {
1375
1526
  if (snapshotsTlsCa) {
1376
1527
  this.declare(`declare void @scr_tls_ca_install()`);
1377
1528
  }
1378
- if (usesEvents && hasRefGlobals) {
1529
+ // Inline exit listeners run when something they must beat exists:
1530
+ // the refcounted-global releases, or the retained-FFI atexit ledger
1531
+ // sweep (a listener may legitimately release or pump a registration,
1532
+ // and only the inline call orders ahead of every atexit handler —
1533
+ // the C emitter's runExitListeners stance). Plain event programs
1534
+ // with neither keep the atexit path, so their listener timing is
1535
+ // unchanged.
1536
+ const hasRefGlobals = globals.some((g) => isRefCounted(g.type)) || fnValueProps.length > 0;
1537
+ const inlineExitListeners = usesEvents && (hasRefGlobals || this.ffiHasRetainedCallback);
1538
+ if (inlineExitListeners) {
1379
1539
  this.declare(`declare void @scr_run_exit_listeners(double)`);
1380
1540
  this.declare(`declare i32 @scr_exit_code_hint_get()`);
1381
1541
  }
1382
1542
  const exitListenerLines = (prefix) => {
1383
- if (!usesEvents || !hasRefGlobals)
1543
+ if (!inlineExitListeners)
1384
1544
  return [];
1385
1545
  return [
1386
1546
  ` %${prefix}h = call i32 @scr_exit_code_hint_get()`,
@@ -1399,6 +1559,7 @@ class LlEmitter {
1399
1559
  // async loop-exhaustion story.
1400
1560
  const runsLoop = this.usesTimers ||
1401
1561
  usesIsland ||
1562
+ this.ffiHasForeignCallback ||
1402
1563
  this.mod.functions.some((f) => f.async === true || f.generator !== undefined);
1403
1564
  const uncaughtReleases = entryMayThrow && !asyncEntry ? globalReleaseLines("gu") : [];
1404
1565
  const loopReleasesU = runsLoop ? globalReleaseLines("gl") : [];
@@ -1421,7 +1582,7 @@ class LlEmitter {
1421
1582
  this.declare(`declare void @scr_promise_rethrow_top_level(ptr)`);
1422
1583
  this.declare(`declare void @scr_promise_release(ptr)`);
1423
1584
  this.declare(`declare void @scr_exit_code_note(i32)`);
1424
- if (programExitUsesIsland && usesEvents && hasRefGlobals) {
1585
+ if (programExitUsesIsland && inlineExitListeners) {
1425
1586
  this.declare(`declare ${this.sizeType} @scr_island_exit_code_version()`);
1426
1587
  }
1427
1588
  }
@@ -1439,7 +1600,7 @@ class LlEmitter {
1439
1600
  lines.push(` %tla_program_exit_zero = icmp eq i32 %tla_program_exit, 0`, ` %tla_exit_status = select i1 %tla_program_exit_zero, i32 %tla_status, i32 %tla_program_exit`);
1440
1601
  }
1441
1602
  const exitStatus = usesNodeTest || usesIsland ? "%tla_exit_status" : "%tla_status";
1442
- const tracksIslandExit = programExitUsesIsland && usesEvents && hasRefGlobals;
1603
+ const tracksIslandExit = programExitUsesIsland && inlineExitListeners;
1443
1604
  if (tracksIslandExit) {
1444
1605
  lines.push(` %tla_exit_version = call ${this.sizeType} @scr_island_exit_code_version()`);
1445
1606
  }
@@ -1508,6 +1669,7 @@ class LlEmitter {
1508
1669
  `%ScrVt = type { ${this.sizeType}, ${this.sizeType}, ptr }`,
1509
1670
  `%ScrUnion = type { ${this.sizeType}, i32, ptr, ptr, ptr, i64 }`,
1510
1671
  `%ScrClosure = type { ${this.sizeType}, ptr, ${this.sizeType}, ptr }`,
1672
+ `%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`,
1511
1673
  `%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr }`,
1512
1674
  // ScrArr mirror { rc, len, cap, elem(i32+pad), elem_retain,
1513
1675
  // elem_release, elem_trace, data } — the immortal tagged-template
@@ -1718,7 +1880,7 @@ class LlEmitter {
1718
1880
  ...(usesEvents ? [` call void @scr_events_install()`] : []),
1719
1881
  // fs.watch programs fill the loop's watch hooks the same way —
1720
1882
  // scr_watch.c links only when this line is emitted.
1721
- ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), ...(usesFetch ? [` call void @scr_fetch_install()`] : []), ...(embedsZlib ? [` call void @scr_zlib_island_install()`] : []), ...(embedsNet ? [` call void @scr_net_island_install()`] : []), ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), ...(usesStream ? [` call void @scr_stream_install()`] : []), ` call void @scr_lib_init(i32 %argc, ptr %argv)`, ...(usesIsland
1883
+ ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), ...(this.ffiHasForeignCallback ? [` call void @scr_ffi_install()`] : []), ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), ...(usesFetch ? [` call void @scr_fetch_install()`] : []), ...(embedsZlib ? [` call void @scr_zlib_island_install()`] : []), ...(embedsNet ? [` call void @scr_net_island_install()`] : []), ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), ...(usesStream ? [` call void @scr_stream_install()`] : []), ` call void @scr_lib_init(i32 %argc, ptr %argv)`, ...(usesIsland
1722
1884
  ? [
1723
1885
  ...(moduleEmbedsCompressedNpm(this.mod)
1724
1886
  ? [` call void @scr_island_set_inflate(ptr @scr_zlib_inflate_exact)`]
@@ -5605,7 +5767,48 @@ class LlEmitter {
5605
5767
  sourceArgs.set(abiIndex, arg);
5606
5768
  if (isFfiCallbackParam(param))
5607
5769
  callbackArgs.set(param.callback.id, arg);
5770
+ if (isFfiReleaseParam(param))
5771
+ callbackArgs.set(param.callback.release, arg);
5608
5772
  });
5773
+ const { registrations: retainedRegistrations, releases: retainedReleases } = collectFfiRetainedOps(entry, callbackArgs, (binding, id) => this.ffiCallbackAdapter(binding, id));
5774
+ if (retainedRegistrations.length > 0) {
5775
+ this.declare(`declare void @scr_ffi_retain(ptr, ptr)`);
5776
+ if (retainedRegistrations.some((registration) => registration.global !== null)) {
5777
+ this.declare(`declare void @scr_ffi_retain_slot(ptr, ptr, ptr)`);
5778
+ this.declare(`declare void @scr_ffi_commit_slot(ptr, ptr)`);
5779
+ }
5780
+ }
5781
+ if (retainedReleases.length > 0) {
5782
+ this.declare(`declare void @scr_ffi_require(ptr, ptr)`);
5783
+ if (retainedReleases.some((release) => release.foreign)) {
5784
+ this.declare(`declare void @scr_ffi_require_foreign(ptr, ptr)`);
5785
+ }
5786
+ }
5787
+ // Pin before registration. Raw retained descriptors are native
5788
+ // singletons: the incoming closure is pinned (and an EMPTY slot
5789
+ // armed) before the native set call, but a replaced registration
5790
+ // stays live and dispatching until the call returns — a native
5791
+ // setter may flush the outgoing callback one last time mid-replace.
5792
+ // scr_ffi_commit_slot below repoints the slot and retires the
5793
+ // superseded pins after the call.
5794
+ for (const registration of retainedRegistrations) {
5795
+ if (registration.global !== null) {
5796
+ B.line(`call void @scr_ffi_retain_slot(ptr @${registration.table}, ptr @${registration.global}, ptr ${registration.callback.name})`);
5797
+ }
5798
+ else if (registration.foreign) {
5799
+ this.declare(`declare void @scr_ffi_retain_foreign(ptr, ptr)`);
5800
+ B.line(`call void @scr_ffi_retain_foreign(ptr @${registration.table}, ptr ${registration.callback.name})`);
5801
+ }
5802
+ else {
5803
+ B.line(`call void @scr_ffi_retain(ptr @${registration.table}, ptr ${registration.callback.name})`);
5804
+ }
5805
+ }
5806
+ // Validate releases BEFORE the native removal call runs: a bogus
5807
+ // release traps without native code observing any side effect. The
5808
+ // registration itself is unpinned only after the call returns.
5809
+ for (const release of retainedReleases) {
5810
+ B.line(`call void @scr_ffi_require${release.foreign ? "_foreign" : ""}(ptr @${release.table}, ptr ${release.callback.name})`);
5811
+ }
5609
5812
  const rawContexts = [];
5610
5813
  for (const param of entry.params) {
5611
5814
  if (!isFfiCallbackParam(param))
@@ -5628,6 +5831,13 @@ class LlEmitter {
5628
5831
  nativeArgs.push(`ptr @${adapter.symbol}`);
5629
5832
  return;
5630
5833
  }
5834
+ if (isFfiReleaseParam(param)) {
5835
+ const { binding, id } = parseFfiCallbackKey(param.callback.release);
5836
+ const adapter = this.ffiCallbackAdapter(binding, id);
5837
+ nativeParamTypes.push("ptr");
5838
+ nativeArgs.push(`ptr @${adapter.symbol}`);
5839
+ return;
5840
+ }
5631
5841
  if (isFfiContextParam(param)) {
5632
5842
  const callback = callbackArgs.get(param.context);
5633
5843
  if (!callback)
@@ -5713,10 +5923,30 @@ class LlEmitter {
5713
5923
  B.line(`store ptr ${saved.previous}, ptr @${saved.tls}`);
5714
5924
  }
5715
5925
  };
5716
- const callbacksMayThrow = callbackArgs.size > 0;
5926
+ const finishRetainedReleases = () => {
5927
+ // Commit raw replacements first (repoint the slot, retire the
5928
+ // superseded pins), then unpin explicit releases — the runtime
5929
+ // disarms the slot itself when the released closure holds it.
5930
+ for (const registration of retainedRegistrations) {
5931
+ if (registration.global !== null) {
5932
+ B.line(`call void @scr_ffi_commit_slot(ptr @${registration.table}, ptr ${registration.callback.name})`);
5933
+ }
5934
+ }
5935
+ if (retainedReleases.some((release) => !release.foreign)) {
5936
+ this.declare(`declare void @scr_ffi_release(ptr, ptr)`);
5937
+ }
5938
+ if (retainedReleases.some((release) => release.foreign)) {
5939
+ this.declare(`declare void @scr_ffi_release_foreign(ptr, ptr)`);
5940
+ }
5941
+ for (const release of retainedReleases) {
5942
+ B.line(`call void @scr_ffi_release${release.foreign ? "_foreign" : ""}(ptr @${release.table}, ptr ${release.callback.name})`);
5943
+ }
5944
+ };
5945
+ const callbacksMayThrow = callbackArgs.size > 0 || this.ffiHasRetainedCallback;
5717
5946
  if (entry.returns === "void") {
5718
5947
  B.line(call);
5719
5948
  restoreRawContexts();
5949
+ finishRetainedReleases();
5720
5950
  if (callbacksMayThrow)
5721
5951
  this.emitPendingCheck();
5722
5952
  return { name: "", type: e.type };
@@ -5724,6 +5954,7 @@ class LlEmitter {
5724
5954
  const raw = B.tmp();
5725
5955
  B.line(`${raw} = ${call}`);
5726
5956
  restoreRawContexts();
5957
+ finishRetainedReleases();
5727
5958
  if (entry.returns === "f64") {
5728
5959
  const result = { name: raw, type: e.type };
5729
5960
  if (callbacksMayThrow)