quickjs-emscripten-sync 1.8.4 → 1.9.1

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/src/vmutil.ts CHANGED
@@ -3,38 +3,108 @@ import type {
3
3
  QuickJSContext,
4
4
  QuickJSHandle,
5
5
  QuickJSDeferredPromise,
6
+ SuccessOrFail,
6
7
  } from "quickjs-emscripten";
7
8
 
9
+ /**
10
+ * Unwrap a VM result, disposing the error handle on failure.
11
+ *
12
+ * `ctx.unwrapResult` throws the error handle without disposing it, which leaks
13
+ * the handle. Under memory pressure the leak cascades: reading the error needs
14
+ * the VM, which is already exhausted, so cleanup is skipped entirely. Here we
15
+ * read the error host-side, always dispose the handle, then throw a host Error.
16
+ */
17
+ export function unwrapResult<T>(ctx: QuickJSContext, result: SuccessOrFail<T, QuickJSHandle>): T {
18
+ if ("error" in result && result.error) {
19
+ const { error } = result;
20
+ let dumped: any;
21
+ try {
22
+ dumped = ctx.dump(error);
23
+ } catch {
24
+ // VM may be unable to read the error (e.g. out of memory); fall back below.
25
+ } finally {
26
+ if (error.alive) error.dispose();
27
+ }
28
+ const err = new Error(
29
+ typeof dumped === "object" && dumped && "message" in dumped
30
+ ? String(dumped.message)
31
+ : dumped !== undefined
32
+ ? String(dumped)
33
+ : "quickjs-emscripten-sync: VM evaluation failed",
34
+ );
35
+ if (typeof dumped === "object" && dumped) {
36
+ if ("name" in dumped) err.name = String(dumped.name);
37
+ if ("stack" in dumped) err.stack = String(dumped.stack);
38
+ }
39
+ throw err;
40
+ }
41
+ return result.value;
42
+ }
43
+
8
44
  export function fn(
9
45
  ctx: QuickJSContext,
10
46
  code: string,
11
47
  ): ((thisArg: QuickJSHandle | undefined, ...args: QuickJSHandle[]) => QuickJSHandle) & Disposable {
12
- const handle = ctx.unwrapResult(ctx.evalCode(code));
48
+ const handle = unwrapResult(ctx, ctx.evalCode(code));
13
49
  const f: any = (thisArg: QuickJSHandle | undefined, ...args: QuickJSHandle[]): any => {
14
- return ctx.unwrapResult(ctx.callFunction(handle, thisArg ?? ctx.undefined, ...args));
50
+ return unwrapResult(ctx, ctx.callFunction(handle, thisArg ?? ctx.undefined, ...args));
15
51
  };
16
52
  const disposeFn = () => handle.dispose();
17
53
  f.dispose = disposeFn;
18
54
  f[Symbol.dispose] = disposeFn;
19
- f.alive = true;
20
55
  Object.defineProperty(f, "alive", {
21
56
  get: () => handle.alive,
22
57
  });
23
58
  return f;
24
59
  }
25
60
 
61
+ // Compiled functions for `call` can be cached per context, keyed by code: the
62
+ // code passed to `call` is always a constant literal, so recompiling the same
63
+ // helper on every call (isHandleObject, defineProperties, etc.) was a dominant
64
+ // cost. Caching is opt-in per context via `enableFnCache` because cached
65
+ // handles outlive a single call and must be disposed with `disposeFnCache`;
66
+ // the Arena enables it in its constructor and disposes it in `dispose`. For
67
+ // contexts without a cache, `call` keeps its original compile-and-dispose
68
+ // behaviour so standalone callers don't leak handles.
69
+ const fnCache = new WeakMap<QuickJSContext, Map<string, QuickJSHandle>>();
70
+
71
+ /** Enable per-context caching of compiled functions used by `call`. */
72
+ export function enableFnCache(ctx: QuickJSContext): void {
73
+ if (!fnCache.has(ctx)) fnCache.set(ctx, new Map());
74
+ }
75
+
76
+ /** Dispose all compiled functions cached for a context and disable caching. */
77
+ export function disposeFnCache(ctx: QuickJSContext): void {
78
+ const cache = fnCache.get(ctx);
79
+ if (!cache) return;
80
+ for (const handle of cache.values()) {
81
+ if (handle.alive) handle.dispose();
82
+ }
83
+ fnCache.delete(ctx);
84
+ }
85
+
26
86
  export function call(
27
87
  ctx: QuickJSContext,
28
88
  code: string,
29
89
  thisArg?: QuickJSHandle,
30
90
  ...args: QuickJSHandle[]
31
91
  ): QuickJSHandle {
32
- const f = fn(ctx, code);
33
- try {
34
- return f(thisArg, ...args);
35
- } finally {
36
- f.dispose();
92
+ const cache = fnCache.get(ctx);
93
+ if (!cache) {
94
+ const f = fn(ctx, code);
95
+ try {
96
+ return f(thisArg, ...args);
97
+ } finally {
98
+ f.dispose();
99
+ }
100
+ }
101
+
102
+ let handle = cache.get(code);
103
+ if (!handle || !handle.alive) {
104
+ handle = unwrapResult(ctx, ctx.evalCode(code));
105
+ cache.set(code, handle);
37
106
  }
107
+ return unwrapResult(ctx, ctx.callFunction(handle, thisArg ?? ctx.undefined, ...args));
38
108
  }
39
109
 
40
110
  export function instanceOf(ctx: QuickJSContext, a: QuickJSHandle, b: QuickJSHandle): boolean {
@@ -42,15 +112,30 @@ export function instanceOf(ctx: QuickJSContext, a: QuickJSHandle, b: QuickJSHand
42
112
  }
43
113
 
44
114
  export function isHandleObject(ctx: QuickJSContext, h: QuickJSHandle): boolean {
45
- return ctx.dump(
46
- call(ctx, `a => typeof a === "object" && a !== null || typeof a === "function"`, undefined, h),
47
- );
115
+ const type = ctx.typeof(h);
116
+ return type === "function" || (type === "object" && !ctx.sameValue(h, ctx.null));
48
117
  }
49
118
 
50
119
  export function json(ctx: QuickJSContext, target: any): QuickJSHandle {
51
120
  const json = JSON.stringify(target);
52
121
  if (!json) return ctx.undefined;
53
- 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));
125
+ }
126
+
127
+ /**
128
+ * Run `cb` with `handle`, then dispose `handle` even if `cb` throws.
129
+ *
130
+ * Unlike `handle.consume`, which skips disposal when its callback throws, this
131
+ * helper disposes in a `finally` so error paths don't leak the handle.
132
+ */
133
+ export function consume<T extends QuickJSHandle, K>(handle: T, cb: (handle: T) => K): K {
134
+ try {
135
+ return cb(handle);
136
+ } finally {
137
+ if (handle.alive) handle.dispose();
138
+ }
54
139
  }
55
140
 
56
141
  export function consumeAll<T extends QuickJSHandle[], K>(handles: T, cb: (handles: T) => K): K {
package/src/wrapper.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { QuickJSHandle, QuickJSContext } from "quickjs-emscripten";
2
2
 
3
3
  import { isObject } from "./util";
4
- import { call, isHandleObject, mayConsumeAll } from "./vmutil";
4
+ import { call, consume, isHandleObject, mayConsumeAll } from "./vmutil";
5
5
 
6
6
  export type SyncMode = "both" | "vm" | "host";
7
7
 
@@ -15,18 +15,28 @@ export function wrap<T = any>(
15
15
  marshal: (target: any) => [QuickJSHandle, boolean],
16
16
  syncMode?: (target: T) => SyncMode | undefined,
17
17
  wrappable?: (target: unknown) => boolean,
18
+ syncEnabled = true,
18
19
  ): Wrapped<T> | undefined {
19
- // promise and date cannot be wrapped
20
+ // These built-ins rely on internal slots or non-property access, so a proxy
21
+ // would break them; they are marshalled by value instead of being wrapped.
20
22
  if (
21
23
  !isObject(target) ||
22
24
  target instanceof Promise ||
23
25
  target instanceof Date ||
26
+ target instanceof ArrayBuffer ||
27
+ ArrayBuffer.isView(target) ||
28
+ target instanceof Map ||
29
+ target instanceof Set ||
24
30
  (wrappable && !wrappable(target))
25
31
  )
26
32
  return undefined;
27
33
 
28
34
  if (isWrapped(target, proxyKeySymbol)) return target;
29
35
 
36
+ // Sync globally disabled: skip the proxy, but still treat the object as
37
+ // "wrapped" so the rest of the pipeline handles it uniformly.
38
+ if (!syncEnabled) return target as Wrapped<T>;
39
+
30
40
  const rec = new Proxy(target as any, {
31
41
  get(obj, key) {
32
42
  return key === proxyKeySymbol ? obj : Reflect.get(obj, key);
@@ -34,7 +44,9 @@ export function wrap<T = any>(
34
44
  set(obj, key, value, receiver) {
35
45
  const v = unwrap(value, proxyKeySymbol);
36
46
  const sync = syncMode?.(receiver) ?? "host";
37
- if ((sync !== "vm" && !Reflect.set(obj, key, v, receiver)) || sync === "host" || !ctx.alive)
47
+ // Set on the target directly (not via `receiver`) so creating a new
48
+ // property does not re-enter the `defineProperty` trap.
49
+ if ((sync !== "vm" && !Reflect.set(obj, key, v)) || sync === "host" || !ctx.alive)
38
50
  return true;
39
51
 
40
52
  mayConsumeAll(
@@ -68,6 +80,35 @@ export function wrap<T = any>(
68
80
  return true;
69
81
  });
70
82
  },
83
+ defineProperty(obj, key, descriptor) {
84
+ const sync = syncMode?.(rec) ?? "host";
85
+ const desc: PropertyDescriptor = { ...descriptor };
86
+ if ("value" in desc) desc.value = unwrap(desc.value, proxyKeySymbol);
87
+ if (typeof desc.get === "function") desc.get = unwrap(desc.get, proxyKeySymbol);
88
+ if (typeof desc.set === "function") desc.set = unwrap(desc.set, proxyKeySymbol);
89
+
90
+ if (sync !== "vm" && !Reflect.defineProperty(obj, key, desc)) return false;
91
+ if (sync === "host" || !ctx.alive) return true;
92
+
93
+ mayConsumeAll(
94
+ [marshal(rec), marshal(key), marshal(desc)],
95
+ (recHandle, keyHandle, descHandle) => {
96
+ const [handle2, unwrapped] = unwrapHandle(ctx, recHandle, proxyKeySymbolHandle);
97
+ const define = (h: QuickJSHandle) =>
98
+ call(
99
+ ctx,
100
+ `(o, k, d) => { Object.defineProperty(o, k, d); }`,
101
+ undefined,
102
+ h,
103
+ keyHandle,
104
+ descHandle,
105
+ ).dispose();
106
+ if (unwrapped) handle2.consume(define);
107
+ else define(handle2);
108
+ },
109
+ );
110
+ return true;
111
+ },
71
112
  }) as Wrapped<T>;
72
113
  return rec;
73
114
  }
@@ -80,12 +121,16 @@ export function wrapHandle(
80
121
  unmarshal: (handle: QuickJSHandle) => any,
81
122
  syncMode?: (target: QuickJSHandle) => SyncMode | undefined,
82
123
  wrappable?: (target: QuickJSHandle, ctx: QuickJSContext) => boolean,
124
+ syncEnabled = true,
83
125
  ): [Wrapped<QuickJSHandle> | undefined, boolean] {
84
126
  if (!isHandleObject(ctx, handle) || (wrappable && !wrappable(handle, ctx)))
85
127
  return [undefined, false];
86
128
 
87
129
  if (isHandleWrapped(ctx, handle, proxyKeySymbolHandle)) return [handle, false];
88
130
 
131
+ // Sync globally disabled: skip the VM-side proxy.
132
+ if (!syncEnabled) return [handle as Wrapped<QuickJSHandle>, false];
133
+
89
134
  const getSyncMode = (h: QuickJSHandle) => {
90
135
  const res = syncMode?.(unmarshal(h));
91
136
  if (typeof res === "string") return ctx.newString(res);
@@ -108,23 +153,35 @@ export function wrapHandle(
108
153
  Reflect.deleteProperty(unwrap(target, proxyKeySymbol), key);
109
154
  };
110
155
 
111
- return ctx
112
- .newFunction("proxyFuncs", (t, ...args) => {
113
- const name = ctx.getNumber(t);
114
- switch (name) {
115
- case 1:
116
- return getSyncMode(args[0]);
117
- case 2:
118
- return setter(args[0], args[1], args[2]);
119
- case 3:
120
- return deleter(args[0], args[1]);
121
- }
122
- return ctx.undefined;
123
- })
124
- .consume(proxyFuncs => [
125
- call(
126
- ctx,
127
- `(target, sym, proxyFuncs) => {
156
+ const definer = (h: QuickJSHandle, keyHandle: QuickJSHandle, descHandle: QuickJSHandle) => {
157
+ const target = unmarshal(h);
158
+ if (!target) return;
159
+ const key = unmarshal(keyHandle);
160
+ if (key === "__proto__") return; // for security
161
+ const desc = unmarshal(descHandle);
162
+ Object.defineProperty(unwrap(target, proxyKeySymbol), key, desc);
163
+ };
164
+
165
+ const proxyFuncs = ctx.newFunction("proxyFuncs", (t, ...args) => {
166
+ const name = ctx.getNumber(t);
167
+ switch (name) {
168
+ case 1:
169
+ return getSyncMode(args[0]);
170
+ case 2:
171
+ return setter(args[0], args[1], args[2]);
172
+ case 3:
173
+ return deleter(args[0], args[1]);
174
+ case 4:
175
+ return definer(args[0], args[1], args[2]);
176
+ }
177
+ return ctx.undefined;
178
+ });
179
+ // Use the exception-safe consume so proxyFuncs is disposed even if compiling
180
+ // the proxy below throws (e.g. under memory pressure).
181
+ return consume(proxyFuncs, proxyFuncs => [
182
+ call(
183
+ ctx,
184
+ `(target, sym, proxyFuncs) => {
128
185
  const rec = new Proxy(target, {
129
186
  get(obj, key, receiver) {
130
187
  return key === sym ? obj : Reflect.get(obj, key, receiver)
@@ -134,7 +191,7 @@ export function wrapHandle(
134
191
  ? value[sym] ?? value
135
192
  : value;
136
193
  const sync = proxyFuncs(1, receiver) ?? "vm";
137
- if (sync === "host" || Reflect.set(obj, key, v, receiver)) {
194
+ if (sync === "host" || Reflect.set(obj, key, v)) {
138
195
  if (sync !== "vm") {
139
196
  proxyFuncs(2, receiver, key, v);
140
197
  }
@@ -150,16 +207,25 @@ export function wrapHandle(
150
207
  }
151
208
  return true;
152
209
  },
210
+ defineProperty(obj, key, descriptor) {
211
+ const sync = proxyFuncs(1, rec) ?? "vm";
212
+ if (sync === "host" || Reflect.defineProperty(obj, key, descriptor)) {
213
+ if (sync !== "vm") {
214
+ proxyFuncs(4, rec, key, descriptor);
215
+ }
216
+ }
217
+ return true;
218
+ },
153
219
  });
154
220
  return rec;
155
221
  }`,
156
- undefined,
157
- handle,
158
- proxyKeySymbolHandle,
159
- proxyFuncs,
160
- ) as Wrapped<QuickJSHandle>,
161
- true,
162
- ]);
222
+ undefined,
223
+ handle,
224
+ proxyKeySymbolHandle,
225
+ proxyFuncs,
226
+ ) as Wrapped<QuickJSHandle>,
227
+ true,
228
+ ]);
163
229
  }
164
230
 
165
231
  export function unwrap<T>(obj: T, key: string | symbol): T {
@@ -187,8 +253,9 @@ export function isHandleWrapped(
187
253
  return !!ctx.dump(
188
254
  call(
189
255
  ctx,
190
- // promise and date cannot be wrapped
191
- `(a, s) => (a instanceof Promise) || (a instanceof Date) || (typeof a === "object" && a !== null || typeof a === "function") && !!a[s]`,
256
+ // Built-ins that must not be wrapped (internal slots / non-property access)
257
+ // report as "wrapped" so wrapHandle leaves them alone.
258
+ `(a, s) => (a instanceof Promise) || (a instanceof Date) || (a instanceof ArrayBuffer) || (ArrayBuffer.isView(a)) || (a instanceof Map) || (a instanceof Set) || (typeof a === "object" && a !== null || typeof a === "function") && !!a[s]`,
192
259
  undefined,
193
260
  handle,
194
261
  key,