@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc → 0.0.0-alpha.1380.bdd09a9f

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/dist/cli.js CHANGED
@@ -1543,14 +1543,20 @@ var gateResultSchema = z2.discriminatedUnion("kind", [
1543
1543
  })
1544
1544
  ]);
1545
1545
  var asyncModulePromise = null;
1546
+ var asyncModule = null;
1546
1547
  function loadAsyncModule() {
1547
1548
  if (!asyncModulePromise) {
1548
1549
  const pending = newQuickJSAsyncWASMModuleFromVariant(
1549
1550
  import("@jitl/quickjs-wasmfile-release-asyncify")
1550
1551
  );
1551
- pending.catch(() => {
1552
- if (asyncModulePromise === pending) asyncModulePromise = null;
1553
- });
1552
+ pending.then(
1553
+ (mod) => {
1554
+ asyncModule = mod;
1555
+ },
1556
+ () => {
1557
+ if (asyncModulePromise === pending) asyncModulePromise = null;
1558
+ }
1559
+ );
1554
1560
  asyncModulePromise = pending;
1555
1561
  }
1556
1562
  return asyncModulePromise;
@@ -1558,9 +1564,20 @@ function loadAsyncModule() {
1558
1564
  async function createVmInstance(opts) {
1559
1565
  const QuickJS = await loadAsyncModule();
1560
1566
  const suspends = Object.keys(opts.hostFns ?? {}).length > 0;
1561
- const runtime = suspends ? null : QuickJS.newRuntime();
1562
- const vm = runtime ? runtime.newContext() : QuickJS.newContext();
1567
+ const runtime = QuickJS.newRuntime();
1568
+ const vm = runtime.newContext();
1563
1569
  vm.runtime.setMemoryLimit(32 * 1024 * 1024);
1570
+ const hostFnNames = Object.keys(opts.hostFns ?? {});
1571
+ function freeVm() {
1572
+ if (hostFnNames.length > 0) {
1573
+ const names = [...hostFnNames, "__app", "__realm_module__"];
1574
+ const release = vm.evalCode(names.map((n) => `globalThis.${n} = undefined;`).join("\n"));
1575
+ if (release.error) release.error.dispose();
1576
+ else release.value.dispose();
1577
+ }
1578
+ vm.dispose();
1579
+ runtime.dispose();
1580
+ }
1564
1581
  for (const [name, fn] of Object.entries(opts.hostFns ?? {})) {
1565
1582
  const hostFn = vm.newAsyncifiedFunction(
1566
1583
  name,
@@ -1577,8 +1594,7 @@ async function createVmInstance(opts) {
1577
1594
  if (init.error) {
1578
1595
  const err = vm.dump(init.error);
1579
1596
  init.error.dispose();
1580
- vm.dispose();
1581
- runtime?.dispose();
1597
+ freeVm();
1582
1598
  throw new RealmCompileError({
1583
1599
  kind: "eval",
1584
1600
  message: typeof err === "string" ? err : JSON.stringify(err)
@@ -1625,8 +1641,7 @@ async function createVmInstance(opts) {
1625
1641
  disposing = true;
1626
1642
  await queue.catch(() => {
1627
1643
  });
1628
- vm.dispose();
1629
- runtime?.dispose();
1644
+ freeVm();
1630
1645
  }
1631
1646
  };
1632
1647
  }
package/dist/test.js CHANGED
@@ -1257,14 +1257,20 @@ var gateResultSchema = z.discriminatedUnion("kind", [
1257
1257
  })
1258
1258
  ]);
1259
1259
  var asyncModulePromise = null;
1260
+ var asyncModule = null;
1260
1261
  function loadAsyncModule() {
1261
1262
  if (!asyncModulePromise) {
1262
1263
  const pending = newQuickJSAsyncWASMModuleFromVariant(
1263
1264
  import("@jitl/quickjs-wasmfile-release-asyncify")
1264
1265
  );
1265
- pending.catch(() => {
1266
- if (asyncModulePromise === pending) asyncModulePromise = null;
1267
- });
1266
+ pending.then(
1267
+ (mod) => {
1268
+ asyncModule = mod;
1269
+ },
1270
+ () => {
1271
+ if (asyncModulePromise === pending) asyncModulePromise = null;
1272
+ }
1273
+ );
1268
1274
  asyncModulePromise = pending;
1269
1275
  }
1270
1276
  return asyncModulePromise;
@@ -1272,9 +1278,20 @@ function loadAsyncModule() {
1272
1278
  async function createVmInstance(opts) {
1273
1279
  const QuickJS = await loadAsyncModule();
1274
1280
  const suspends = Object.keys(opts.hostFns ?? {}).length > 0;
1275
- const runtime = suspends ? null : QuickJS.newRuntime();
1276
- const vm = runtime ? runtime.newContext() : QuickJS.newContext();
1281
+ const runtime = QuickJS.newRuntime();
1282
+ const vm = runtime.newContext();
1277
1283
  vm.runtime.setMemoryLimit(32 * 1024 * 1024);
1284
+ const hostFnNames = Object.keys(opts.hostFns ?? {});
1285
+ function freeVm() {
1286
+ if (hostFnNames.length > 0) {
1287
+ const names = [...hostFnNames, "__app", "__realm_module__"];
1288
+ const release = vm.evalCode(names.map((n) => `globalThis.${n} = undefined;`).join("\n"));
1289
+ if (release.error) release.error.dispose();
1290
+ else release.value.dispose();
1291
+ }
1292
+ vm.dispose();
1293
+ runtime.dispose();
1294
+ }
1278
1295
  for (const [name, fn] of Object.entries(opts.hostFns ?? {})) {
1279
1296
  const hostFn = vm.newAsyncifiedFunction(
1280
1297
  name,
@@ -1291,8 +1308,7 @@ async function createVmInstance(opts) {
1291
1308
  if (init.error) {
1292
1309
  const err = vm.dump(init.error);
1293
1310
  init.error.dispose();
1294
- vm.dispose();
1295
- runtime?.dispose();
1311
+ freeVm();
1296
1312
  throw new RealmCompileError({
1297
1313
  kind: "eval",
1298
1314
  message: typeof err === "string" ? err : JSON.stringify(err)
@@ -1339,8 +1355,7 @@ async function createVmInstance(opts) {
1339
1355
  disposing = true;
1340
1356
  await queue.catch(() => {
1341
1357
  });
1342
- vm.dispose();
1343
- runtime?.dispose();
1358
+ freeVm();
1344
1359
  }
1345
1360
  };
1346
1361
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yourrealm/sdk",
3
- "version": "0.0.0-alpha.1378.ce03c8dc",
3
+ "version": "0.0.0-alpha.1380.bdd09a9f",
4
4
  "description": "The Realm SDK: createApp, the r.* permission factories, the ui surface, the realm-sdk CLI and test harness.",
5
5
  "license": "AGPL-3.0-only WITH Classpath-exception-2.0",
6
6
  "repository": {
package/src/compile.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  sweepExecuteCache,
21
21
  runAppGate,
22
22
  disposeAllVms,
23
+ sandboxArenaBytes,
23
24
  stitchLoaderRequests,
24
25
  snapshot,
25
26
  nestByPaths,
package/src/sandbox.ts CHANGED
@@ -759,6 +759,19 @@ interface VmInstance {
759
759
  }
760
760
 
761
761
  let asyncModulePromise: ReturnType<typeof newQuickJSAsyncWASMModuleFromVariant> | null = null;
762
+ let asyncModule: Awaited<ReturnType<typeof newQuickJSAsyncWASMModuleFromVariant>> | null = null;
763
+
764
+ /**
765
+ * Bytes of WebAssembly linear memory the QuickJS module holds, or `null` before
766
+ * the first eval loads it. This is the sandbox's true footprint: every runtime
767
+ * mallocs out of this one arena, and the arena only ever grows - Emscripten
768
+ * cannot hand pages back - so the number is the peak concurrent demand plus
769
+ * anything a dispose failed to free. V8 does not count it under `external`, so
770
+ * it is otherwise invisible in `Deno.memoryUsage()`.
771
+ */
772
+ export function sandboxArenaBytes(): number | null {
773
+ return asyncModule ? asyncModule.getWasmMemory().buffer.byteLength : null;
774
+ }
762
775
 
763
776
  // Memoize the async WASM module load (shared by runInSandbox + the gate), but
764
777
  // never cache a rejection: a transient load failure must not permanently wedge
@@ -773,9 +786,14 @@ function loadAsyncModule(): ReturnType<typeof newQuickJSAsyncWASMModuleFromVaria
773
786
  const pending = newQuickJSAsyncWASMModuleFromVariant(
774
787
  import("@jitl/quickjs-wasmfile-release-asyncify"),
775
788
  );
776
- pending.catch(() => {
777
- if (asyncModulePromise === pending) asyncModulePromise = null;
778
- });
789
+ pending.then(
790
+ (mod) => {
791
+ asyncModule = mod;
792
+ },
793
+ () => {
794
+ if (asyncModulePromise === pending) asyncModulePromise = null;
795
+ },
796
+ );
779
797
  asyncModulePromise = pending;
780
798
  }
781
799
  return asyncModulePromise;
@@ -802,22 +820,41 @@ async function createVmInstance(opts: {
802
820
  const QuickJS = await loadAsyncModule();
803
821
  const suspends = Object.keys(opts.hostFns ?? {}).length > 0;
804
822
 
805
- // Creation and disposal differ by mode, and the two must match:
806
- // - Sync VMs (no host fns: the cold extracts + widget prefill) own their
807
- // runtime explicitly so dispose() can call `runtime.dispose()` and RECLAIM
808
- // the WASM heap. The shorthand `QuickJS.newContext()` + `context.dispose()`
809
- // does NOT reclaim the runtime in quickjs-emscripten 0.32: every
810
- // create/dispose leaks ~5MB, which over a control plane's recompute
811
- // bursts climbs into gigabytes until `WebAssembly.Memory.grow()` fails
812
- // and every eval OOMs.
813
- // - Asyncify VMs (host fns: the gate's `stableSecret`) must use
814
- // `QuickJS.newContext()`, whose single `vm.dispose()` frees the host fn's
815
- // runtime-scoped HostRef in the right order; disposing an explicit runtime
816
- // there double-frees the HostRef and throws. These are warm-pooled and
817
- // disposed rarely, so the (unreclaimed) heap doesn't accumulate.
818
- const runtime = suspends ? null : QuickJS.newRuntime();
819
- const vm = runtime ? runtime.newContext() : QuickJS.newContext();
823
+ // Every VM owns its runtime explicitly, whichever eval mode it runs in. The
824
+ // shorthand `QuickJS.newContext()` leaves its runtime alive after
825
+ // `context.dispose()` in quickjs-emscripten 0.32, and the WASM arena those
826
+ // runtimes sit in never shrinks: each create/dispose then pins ~5MB for the
827
+ // life of the process. Home's gate VMs churned through that path once per
828
+ // idle-out and grew the arena by ~700MB in a day and a half (on a 2GB
829
+ // ceiling, past which every eval OOMs). See `freeVm` for the dispose order
830
+ // the explicit runtime needs when host fns are bound.
831
+ const runtime = QuickJS.newRuntime();
832
+ const vm = runtime.newContext();
820
833
  vm.runtime.setMemoryLimit(32 * 1024 * 1024); // 32 MB
834
+ const hostFnNames = Object.keys(opts.hostFns ?? {});
835
+
836
+ // Free the context and its runtime, in the order the host fns need. A bound
837
+ // host fn is a QuickJS function object whose finalizer calls back into the
838
+ // module to free its HostRef, looked up by runtime. When that object is still
839
+ // reachable while `vm.dispose()` tears the context down, the lookup fails
840
+ // ("QuickJSRuntime not found when trying to free HostRef"), the dispose throws
841
+ // half-way and the runtime is never freed. Dropping every global that can
842
+ // reach a host fn first lets the refcount hit zero and the finalizer run while
843
+ // the runtime is still registered; then both disposes complete. If
844
+ // `vm.dispose()` still throws, the runtime is deliberately left alone - a
845
+ // `runtime.dispose()` over a half-freed context aborts the whole Emscripten
846
+ // module - so that path leaks one VM rather than wedging every later eval.
847
+ function freeVm(): void {
848
+ if (hostFnNames.length > 0) {
849
+ const names = [...hostFnNames, "__app", "__realm_module__"];
850
+ const release = vm.evalCode(names.map((n) => `globalThis.${n} = undefined;`).join("\n"));
851
+ if (release.error) release.error.dispose();
852
+ else release.value.dispose();
853
+ }
854
+ vm.dispose();
855
+ runtime.dispose();
856
+ }
857
+
821
858
  for (const [name, fn] of Object.entries(opts.hostFns ?? {})) {
822
859
  const hostFn = vm.newAsyncifiedFunction(name, async (argHandle) =>
823
860
  vm.newString(await fn(vm.getString(argHandle))),
@@ -836,8 +873,7 @@ async function createVmInstance(opts: {
836
873
  if (init.error) {
837
874
  const err = vm.dump(init.error);
838
875
  init.error.dispose();
839
- vm.dispose();
840
- runtime?.dispose();
876
+ freeVm();
841
877
  throw new RealmCompileError({
842
878
  kind: "eval",
843
879
  message: typeof err === "string" ? err : JSON.stringify(err),
@@ -891,8 +927,7 @@ async function createVmInstance(opts: {
891
927
  // dispose mid-call (an eviction during a suspended host fn) would otherwise
892
928
  // resume the asyncified eval into a freed context and hang.
893
929
  await queue.catch(() => {});
894
- vm.dispose();
895
- runtime?.dispose();
930
+ freeVm();
896
931
  },
897
932
  };
898
933
  }