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.
- package/README.md +9 -0
- package/dist/index.d.ts +7 -0
- package/dist/quickjs-emscripten-sync.js +424 -372
- package/dist/quickjs-emscripten-sync.umd.cjs +7 -7
- package/package.json +2 -1
- package/src/edge.test.ts +44 -2
- package/src/identity.test.ts +57 -0
- package/src/index.test.ts +20 -0
- package/src/index.ts +53 -2
- package/src/jsonleak.test.ts +178 -0
- package/src/marshal/function.test.ts +6 -4
- package/src/marshal/function.ts +25 -4
- package/src/marshal/index.ts +28 -3
- package/src/marshal/mapset.ts +11 -2
- package/src/marshal/object.ts +4 -1
- package/src/marshal/properties.ts +11 -0
- package/src/remarshalleak.test.ts +50 -0
- package/src/vmmap.ts +5 -1
- package/src/vmutil.ts +3 -1
package/src/marshal/function.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/marshal/index.ts
CHANGED
|
@@ -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(
|
|
66
|
-
|
|
67
|
-
|
|
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
|
}
|
package/src/marshal/mapset.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/marshal/object.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
/**
|