quickjs-emscripten-sync 1.9.0 → 1.10.0

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.
@@ -12,24 +12,45 @@ export default function marshalFunction(
12
12
  unmarshal: (handle: QuickJSHandle) => unknown,
13
13
  preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined,
14
14
  preApply?: (target: (...args: any[]) => any, thisArg: unknown, args: unknown[]) => any,
15
+ disposeTransient: (handle: QuickJSHandle) => void = () => {},
16
+ prepareReturn: (handle: QuickJSHandle) => QuickJSHandle = h => h,
15
17
  ): QuickJSHandle | undefined {
16
18
  if (typeof target !== "function") return;
17
19
 
18
20
  const raw = ctx
19
21
  .newFunction(target.name, function (...argHandles) {
20
- const that = unmarshal(this);
22
+ // A plain call (`fn()`) passes the VM global object as `this`. Unmarshalling
23
+ // it would eagerly deep-copy the entire global graph (hundreds of handles)
24
+ // on the first call, for a `this` host functions almost never use — and
25
+ // leaking globalThis to the host is undesirable. So global `this` is passed
26
+ // to the host function as `undefined`, which differs from plain JS where a
27
+ // non-strict function sees `this === globalThis` (see README Limitations).
28
+ // Real method calls still get their receiver unmarshalled.
29
+ const that = ctx.sameValue(this, ctx.global) ? undefined : unmarshal(this);
21
30
  const args = argHandles.map(a => unmarshal(a));
22
31
 
23
32
  if (isES2015Class(target) && isObject(that)) {
24
33
  // Class constructors cannot be invoked without new expression, and new.target is not changed
25
34
  const result = new target(...args);
26
35
  Object.entries(result).forEach(([key, value]) => {
27
- ctx.setProp(this, key, marshal(value));
36
+ const valueHandle = marshal(value);
37
+ ctx.setProp(this, key, valueHandle);
38
+ // setProp dup'd the value into `this`; drop ours if it was transient.
39
+ disposeTransient(valueHandle);
28
40
  });
29
41
  return this;
30
42
  }
31
43
 
32
- return marshal(preApply ? preApply(target as (...args: any[]) => any, that, args) : (target as (...args: any[]) => any).apply(that, args));
44
+ // The VM disposes whatever we return here. `prepareReturn` dups the
45
+ // handle when the VMMap retains it, so the map keeps a live copy and
46
+ // identity (`x === fn()` across calls) survives instead of going stale.
47
+ return prepareReturn(
48
+ marshal(
49
+ preApply
50
+ ? preApply(target as (...args: any[]) => any, that, args)
51
+ : (target as (...args: any[]) => any).apply(that, args),
52
+ ),
53
+ );
33
54
  })
34
55
  .consume(handle2 =>
35
56
  // fucntions created by vm.newFunction are not callable as a class constrcutor
@@ -47,7 +68,7 @@ export default function marshalFunction(
47
68
  );
48
69
 
49
70
  const handle = preMarshal(target, raw) ?? raw;
50
- marshalProperties(ctx, target, raw, marshal);
71
+ marshalProperties(ctx, target, raw, marshal, disposeTransient);
51
72
 
52
73
  return handle;
53
74
  }
@@ -16,12 +16,23 @@ export type Options = {
16
16
  marshalByReference?: (target: unknown) => boolean;
17
17
  registerHostRef?: (target: unknown, handle: QuickJSHandle) => QuickJSHandle;
18
18
  find: (target: unknown) => QuickJSHandle | undefined;
19
+ // Track a handle that nobody else owns (json copies, BigInt) so it can be
20
+ // disposed once consumed.
21
+ registerTransient?: (handle: QuickJSHandle) => void;
22
+ // Dispose a handle previously registered as transient, right after a parent
23
+ // consumer has copied it into the parent value. A no-op for owned handles.
24
+ disposeTransient?: (handle: QuickJSHandle) => void;
19
25
  pre: (
20
26
  target: unknown,
21
27
  handle: QuickJSHandle | QuickJSDeferredPromise,
22
28
  mode: true | "json" | undefined,
23
29
  ) => QuickJSHandle | undefined;
24
30
  preApply?: (target: (...args: any[]) => any, thisArg: unknown, args: unknown[]) => any;
31
+ // Adjust ownership of a handle that is about to be handed to the VM as a host
32
+ // function's return value. The VM disposes whatever it receives, so a handle
33
+ // the VMMap retains (for identity, while sync is on) must be dup'd here or its
34
+ // map entry goes stale. Defaults to identity (no-op).
35
+ prepareReturn?: (handle: QuickJSHandle) => QuickJSHandle;
25
36
  custom?: Iterable<(obj: unknown, ctx: QuickJSContext) => QuickJSHandle | undefined>;
26
37
  };
27
38
 
@@ -31,6 +42,10 @@ export function marshal(target: unknown, options: Options): QuickJSHandle {
31
42
  {
32
43
  const primitive = marshalPrimitive(ctx, target);
33
44
  if (primitive) {
45
+ // BigInt handles are heap-allocated and, unlike strings and numbers, are
46
+ // not reclaimed unless explicitly disposed. Track them as transient so a
47
+ // nested one is freed by its parent consumer (or on dispose as a fallback).
48
+ if (typeof target === "bigint") options.registerTransient?.(primitive);
34
49
  return primitive;
35
50
  }
36
51
  }
@@ -59,12 +74,22 @@ export function marshal(target: unknown, options: Options): QuickJSHandle {
59
74
  }
60
75
 
61
76
  const marshal2 = (t: unknown) => marshal(t, options);
77
+ const disposeTransient = options.disposeTransient;
62
78
  return (
63
79
  marshalCustom(ctx, target, pre2, [...defaultCustom, ...(options.custom ?? [])]) ??
64
80
  marshalPromise(ctx, target, marshal2, pre2) ??
65
- marshalFunction(ctx, target, marshal2, unmarshal, pre2, options.preApply) ??
66
- marshalMapSet(ctx, target, marshal2, pre2) ??
67
- marshalObject(ctx, target, marshal2, pre2) ??
81
+ marshalFunction(
82
+ ctx,
83
+ target,
84
+ marshal2,
85
+ unmarshal,
86
+ pre2,
87
+ options.preApply,
88
+ disposeTransient,
89
+ options.prepareReturn,
90
+ ) ??
91
+ marshalMapSet(ctx, target, marshal2, pre2, disposeTransient) ??
92
+ marshalObject(ctx, target, marshal2, pre2, disposeTransient) ??
68
93
  ctx.undefined
69
94
  );
70
95
  }
@@ -7,12 +7,18 @@ export default function marshalMapSet(
7
7
  target: unknown,
8
8
  marshal: (target: unknown) => QuickJSHandle,
9
9
  preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined,
10
+ disposeTransient: (handle: QuickJSHandle) => void = () => {},
10
11
  ): QuickJSHandle | undefined {
11
12
  if (target instanceof Map) {
12
13
  const raw = call(ctx, "() => new Map()");
13
14
  const handle = preMarshal(target, raw) ?? raw;
14
15
  for (const [k, v] of target) {
15
- call(ctx, "(m, k, v) => m.set(k, v)", undefined, raw, marshal(k), marshal(v)).dispose();
16
+ const kh = marshal(k);
17
+ const vh = marshal(v);
18
+ call(ctx, "(m, k, v) => m.set(k, v)", undefined, raw, kh, vh).dispose();
19
+ // set() has taken its own references; drop ours if they were transient.
20
+ disposeTransient(kh);
21
+ disposeTransient(vh);
16
22
  }
17
23
  return handle;
18
24
  }
@@ -21,7 +27,10 @@ export default function marshalMapSet(
21
27
  const raw = call(ctx, "() => new Set()");
22
28
  const handle = preMarshal(target, raw) ?? raw;
23
29
  for (const v of target) {
24
- call(ctx, "(s, v) => s.add(v)", undefined, raw, marshal(v)).dispose();
30
+ const vh = marshal(v);
31
+ call(ctx, "(s, v) => s.add(v)", undefined, raw, vh).dispose();
32
+ // add() has taken its own reference; drop ours if it was transient.
33
+ disposeTransient(vh);
25
34
  }
26
35
  return handle;
27
36
  }
@@ -9,6 +9,7 @@ export default function marshalObject(
9
9
  target: unknown,
10
10
  marshal: (target: unknown) => QuickJSHandle,
11
11
  preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined,
12
+ disposeTransient: (handle: QuickJSHandle) => void = () => {},
12
13
  ): QuickJSHandle | undefined {
13
14
  if (typeof target !== "object" || target === null) return;
14
15
 
@@ -23,9 +24,11 @@ export default function marshalObject(
23
24
  : undefined;
24
25
  if (prototypeHandle) {
25
26
  call(ctx, "Object.setPrototypeOf", undefined, handle, prototypeHandle).dispose();
27
+ // setPrototypeOf has taken its own reference; drop ours if it was transient.
28
+ disposeTransient(prototypeHandle);
26
29
  }
27
30
 
28
- marshalProperties(ctx, target, raw, marshal);
31
+ marshalProperties(ctx, target, raw, marshal, disposeTransient);
29
32
 
30
33
  return handle;
31
34
  }
@@ -7,13 +7,21 @@ export default function marshalProperties(
7
7
  target: object | ((...args: any[]) => any),
8
8
  handle: QuickJSHandle,
9
9
  marshal: (target: unknown) => QuickJSHandle,
10
+ disposeTransient: (handle: QuickJSHandle) => void = () => {},
10
11
  ): void {
11
12
  const descs = ctx.newObject();
13
+ // Property values may be transient (json copies / BigInt) with no owner; once
14
+ // `Object.defineProperties` has copied them into `handle` the standalone
15
+ // handles are redundant and disposed below. Owned handles are left untouched.
16
+ const transient: QuickJSHandle[] = [];
12
17
  const cb = (key: string | number | symbol, desc: PropertyDescriptor) => {
13
18
  const keyHandle = marshal(key);
14
19
  const valueHandle = typeof desc.value === "undefined" ? undefined : marshal(desc.value);
15
20
  const getHandle = typeof desc.get === "undefined" ? undefined : marshal(desc.get);
16
21
  const setHandle = typeof desc.set === "undefined" ? undefined : marshal(desc.set);
22
+ if (valueHandle) transient.push(valueHandle);
23
+ if (getHandle) transient.push(getHandle);
24
+ if (setHandle) transient.push(setHandle);
17
25
 
18
26
  ctx.newObject().consume(descObj => {
19
27
  Object.entries(desc).forEach(([k, v]) => {
@@ -41,6 +49,9 @@ export default function marshalProperties(
41
49
  Object.getOwnPropertySymbols(desc).forEach(k => cb(k, (desc as any)[k]));
42
50
 
43
51
  call(ctx, `Object.defineProperties`, undefined, handle, descs).dispose();
52
+ // Safe only after defineProperties has dup'd the values into `handle`; `descs`
53
+ // still holds its own references until its own dispose() in the finally.
54
+ for (const h of transient) disposeTransient(h);
44
55
  } finally {
45
56
  descs.dispose();
46
57
  }
@@ -0,0 +1,50 @@
1
+ import variant from "@jitl/quickjs-wasmfile-debug-sync";
2
+ import { newQuickJSWASMModuleFromVariant } from "quickjs-emscripten";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Arena } from ".";
6
+
7
+ // Marshalling the same host object into the VM more than once used to leak a
8
+ // handle: the first registration stores both a wrapped (proxy) handle and its
9
+ // unwrapped sibling. Once the VM frees the proxy, the map entry goes stale, and
10
+ // the lazy eviction in VMMap.get dropped the entry without disposing the
11
+ // still-alive sibling. The debug-sync runtime aborts on dispose if any GC
12
+ // object handle leaked, so reaching ctx.dispose() without an abort proves the
13
+ // sibling is now released.
14
+ async function withArena(fn: (arena: Arena) => void) {
15
+ const mod = await newQuickJSWASMModuleFromVariant(variant as any);
16
+ const ctx = mod.newContext();
17
+ const arena = new Arena(ctx, { isMarshalable: true });
18
+ fn(arena);
19
+ arena.dispose();
20
+ expect(() => ctx.dispose()).not.toThrow();
21
+ }
22
+
23
+ describe("re-marshal handle leak", () => {
24
+ // Calling an exposed function from inside the VM marshals the whole global
25
+ // graph on each call, which is slow under the debug-sync runtime + coverage,
26
+ // so these get a generous timeout (cf. the "many newFunction" edge test).
27
+ it(
28
+ "does not leak when a host function returns the same object twice",
29
+ async () => {
30
+ await withArena(arena => {
31
+ const shared = { k: 1 };
32
+ arena.expose({ get: () => shared });
33
+ arena.evalCode(`get(); get();`);
34
+ });
35
+ },
36
+ 90000,
37
+ );
38
+
39
+ it(
40
+ "does not leak when the same object is compared across two calls",
41
+ async () => {
42
+ await withArena(arena => {
43
+ const shared = { k: 1 };
44
+ arena.expose({ get: () => shared });
45
+ arena.evalCode(`get() === get();`);
46
+ });
47
+ },
48
+ 90000,
49
+ );
50
+ });
package/src/vmmap.ts CHANGED
@@ -121,7 +121,11 @@ export default class VMMap {
121
121
 
122
122
  if (!handle) return;
123
123
  if (!handle.alive) {
124
- this.delete(key);
124
+ // The primary handle was freed by the VM, so this entry is stale. Dispose
125
+ // its sibling handle while evicting it: otherwise the sibling (e.g. the
126
+ // unwrapped handle registered alongside a proxy) is orphaned and leaks
127
+ // when the same value is marshalled again.
128
+ this.delete(key, true);
125
129
  return;
126
130
  }
127
131
 
package/src/vmutil.ts CHANGED
@@ -119,7 +119,9 @@ export function isHandleObject(ctx: QuickJSContext, h: QuickJSHandle): boolean {
119
119
  export function json(ctx: QuickJSContext, target: any): QuickJSHandle {
120
120
  const json = JSON.stringify(target);
121
121
  if (!json) return ctx.undefined;
122
- return call(ctx, `JSON.parse`, undefined, ctx.newString(json));
122
+ // `call` does not dispose its arguments, so the intermediate string handle
123
+ // must be consumed here or it leaks on every json marshal.
124
+ return ctx.newString(json).consume(s => call(ctx, `JSON.parse`, undefined, s));
123
125
  }
124
126
 
125
127
  /**