Skip to content

Commit c9fb471

Browse files
committed
feat: add syncEnabled option to disable sync globally (closes #31)
When syncEnabled is false, objects are not wrapped with proxies and marshalled handles are reported as disposable, so arena.sync has no effect but short-lived objects exchanged across the boundary are not retained for the arena's whole lifetime. This avoids VM memory growth when, e.g., a host function returning fresh objects is called in a loop. Defaults to true, preserving existing behaviour. Re-implements the idea from #31 on the current codebase, with a test showing object count stays flat with sync off and grows with it on.
1 parent 08d652f commit c9fb471

4 files changed

Lines changed: 54 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ type Options = {
217217
isHandleWrappable?: (handle: QuickJSHandle, ctx: QuickJSContext) => boolean;
218218
/** Compatibility with quickjs-emscripten prior to v0.15. Inject code for compatibility into context at Arena class initialization time. */
219219
compat?: boolean;
220+
/** Globally enable sync mode (default `true`). When `false`, objects are not wrapped with proxies and marshalled handles are disposed after use, so `arena.sync` has no effect but objects are not retained for their whole lifetime. Useful to avoid memory growth when frequently exchanging short-lived objects. */
221+
syncEnabled?: boolean;
220222
}
221223
```
222224

src/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,42 @@ describe("intrinsics configuration", () => {
10371037
});
10381038
});
10391039

1040+
describe("syncEnabled", () => {
1041+
const objCount = (ctx: any) => {
1042+
const h = ctx.runtime.computeMemoryUsage();
1043+
const c = ctx.dump(h).obj_count as number;
1044+
h.dispose();
1045+
return c;
1046+
};
1047+
1048+
const growth = async (syncEnabled: boolean) => {
1049+
const ctx = (await getQuickJS()).newContext();
1050+
const arena = new Arena(ctx, { isMarshalable: true, registeredObjects: [], syncEnabled });
1051+
1052+
arena.expose({ fnFromHost: () => ({ id: "x", data: Math.random() }) });
1053+
arena.evalCode(`globalThis.run = () => { for (let i = 0; i < 200; i++) fnFromHost(); }`);
1054+
1055+
arena.evalCode(`run()`);
1056+
const before = objCount(ctx);
1057+
arena.evalCode(`run()`);
1058+
const after = objCount(ctx);
1059+
1060+
arena.dispose();
1061+
ctx.dispose();
1062+
return after - before;
1063+
};
1064+
1065+
test("syncEnabled: false does not retain marshalled objects", async () => {
1066+
// returned objects are not retained, so repeated runs don't grow memory
1067+
expect(await growth(false)).toBeLessThan(50);
1068+
});
1069+
1070+
test("syncEnabled: true retains marshalled objects (default)", async () => {
1071+
// with sync on, every returned object is kept for identity, so memory grows
1072+
expect(await growth(true)).toBeGreaterThan(150);
1073+
});
1074+
});
1075+
10401076
describe("AsyncArena", () => {
10411077
test("evalCodeAsync returns values", async () => {
10421078
const ctx = await newAsyncContext();

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export type Options = {
6464
compat?: boolean;
6565
/** Experimental: use QuickJSContextEx, which wraps existing QuickJSContext. */
6666
experimentalContextEx?: boolean;
67+
/** Globally enable sync mode (default `true`). When `false`, objects are not wrapped with proxies and marshalled handles are disposed after use, so `arena.sync` has no effect but objects are not retained for their whole lifetime. Useful to avoid memory growth when frequently exchanging short-lived objects. */
68+
syncEnabled?: boolean;
6769
};
6870

6971
/**
@@ -433,11 +435,12 @@ export class Arena {
433435
custom: this._options?.customMarshaller,
434436
});
435437

436-
return [handle, !this._map.hasHandle(handle)];
438+
const syncEnabled = this._options?.syncEnabled ?? true;
439+
return [handle, !syncEnabled || !this._map.hasHandle(handle)];
437440
};
438441

439442
_preUnmarshal = (t: any, h: QuickJSHandle): Wrapped<any> => {
440-
return this._register(t, h, undefined, true)?.[0];
443+
return this._register(t, h, undefined, this._options?.syncEnabled ?? true)?.[0];
441444
};
442445

443446
_unmarshalFind = (h: QuickJSHandle): unknown => {
@@ -505,6 +508,7 @@ export class Arena {
505508
this._marshal,
506509
this._syncMode,
507510
this._options?.isWrappable,
511+
this._options?.syncEnabled ?? true,
508512
);
509513
}
510514

@@ -526,6 +530,7 @@ export class Arena {
526530
this._unmarshal,
527531
this._syncMode,
528532
this._options?.isHandleWrappable,
533+
this._options?.syncEnabled ?? true,
529534
);
530535
}
531536

src/wrapper.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function wrap<T = any>(
1515
marshal: (target: any) => [QuickJSHandle, boolean],
1616
syncMode?: (target: T) => SyncMode | undefined,
1717
wrappable?: (target: unknown) => boolean,
18+
syncEnabled = true,
1819
): Wrapped<T> | undefined {
1920
// These built-ins rely on internal slots or non-property access, so a proxy
2021
// would break them; they are marshalled by value instead of being wrapped.
@@ -32,6 +33,10 @@ export function wrap<T = any>(
3233

3334
if (isWrapped(target, proxyKeySymbol)) return target;
3435

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+
3540
const rec = new Proxy(target as any, {
3641
get(obj, key) {
3742
return key === proxyKeySymbol ? obj : Reflect.get(obj, key);
@@ -85,12 +90,16 @@ export function wrapHandle(
8590
unmarshal: (handle: QuickJSHandle) => any,
8691
syncMode?: (target: QuickJSHandle) => SyncMode | undefined,
8792
wrappable?: (target: QuickJSHandle, ctx: QuickJSContext) => boolean,
93+
syncEnabled = true,
8894
): [Wrapped<QuickJSHandle> | undefined, boolean] {
8995
if (!isHandleObject(ctx, handle) || (wrappable && !wrappable(handle, ctx)))
9096
return [undefined, false];
9197

9298
if (isHandleWrapped(ctx, handle, proxyKeySymbolHandle)) return [handle, false];
9399

100+
// Sync globally disabled: skip the VM-side proxy.
101+
if (!syncEnabled) return [handle as Wrapped<QuickJSHandle>, false];
102+
94103
const getSyncMode = (h: QuickJSHandle) => {
95104
const res = syncMode?.(unmarshal(h));
96105
if (typeof res === "string") return ctx.newString(res);

0 commit comments

Comments
 (0)