quickjs-emscripten-sync 1.9.1 → 1.11.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.
@@ -0,0 +1,57 @@
1
+ import { getQuickJS } from "quickjs-emscripten";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Arena } from ".";
5
+
6
+ // A host function's return value is disposed by the VM once consumed. Marshalled
7
+ // object handles are retained in the VMMap for identity while sync is on, so the
8
+ // returned handle must be dup'd — otherwise the map entry goes stale and the same
9
+ // value marshalled twice yields two distinct VM objects. These tests pin the
10
+ // identity behaviour (issue #4); the no-leak side is covered by
11
+ // remarshalleak.test.ts under the debug-sync runtime.
12
+ async function withArena(
13
+ options: ConstructorParameters<typeof Arena>[1],
14
+ fn: (arena: Arena) => void,
15
+ ) {
16
+ const ctx = (await getQuickJS()).newContext();
17
+ const arena = new Arena(ctx, options);
18
+ fn(arena);
19
+ arena.dispose();
20
+ ctx.dispose();
21
+ }
22
+
23
+ describe("marshal identity", () => {
24
+ it("preserves identity for a VM object round-tripped through the host", async () => {
25
+ await withArena({ isMarshalable: true }, arena => {
26
+ arena.expose({ id: (x: any) => x });
27
+ // foo originates in the VM, is passed to the host and returned: the
28
+ // round-trip must yield the same object.
29
+ expect(arena.evalCode(`let foo = id({}); foo === id(foo)`)).toBe(true);
30
+ });
31
+ });
32
+
33
+ it("returns the same VM object when a host function returns the same object twice", async () => {
34
+ await withArena({ isMarshalable: true }, arena => {
35
+ const shared = { k: 1 };
36
+ arena.expose({ get: () => shared });
37
+ expect(arena.evalCode(`get() === get()`)).toBe(true);
38
+ });
39
+ });
40
+
41
+ it("keeps identity across retained references", async () => {
42
+ await withArena({ isMarshalable: true }, arena => {
43
+ const shared = { k: 1 };
44
+ arena.expose({ get: () => shared });
45
+ expect(arena.evalCode(`let a = get(); let b = get(); a === b`)).toBe(true);
46
+ });
47
+ });
48
+
49
+ it("does not retain identity when sync is disabled", async () => {
50
+ await withArena({ isMarshalable: true, syncEnabled: false }, arena => {
51
+ const shared = { k: 1 };
52
+ arena.expose({ get: () => shared });
53
+ // With sync off, objects are not retained, so each marshal is independent.
54
+ expect(arena.evalCode(`get() === get()`)).toBe(false);
55
+ });
56
+ });
57
+ });
package/src/index.test.ts CHANGED
@@ -73,6 +73,70 @@ describe("readme", () => {
73
73
  });
74
74
  });
75
75
 
76
+ describe("class constructors (#92)", () => {
77
+ test("new on an exposed class inside the VM", async () => {
78
+ const ctx = (await getQuickJS()).newContext();
79
+ const arena = new Arena(ctx, { isMarshalable: true });
80
+
81
+ class Cls {
82
+ hoge = "";
83
+
84
+ constructor() {
85
+ this.hoge = "foo";
86
+ }
87
+ }
88
+ arena.expose({ Cls });
89
+
90
+ const instance = arena.evalCode(`new Cls()`) as { hoge: string };
91
+ expect(instance.hoge).toBe("foo");
92
+ expect(arena.evalCode(`new Cls() instanceof Cls`)).toBe(true);
93
+
94
+ arena.dispose();
95
+ ctx.dispose();
96
+ });
97
+
98
+ test("constructor arguments reach the host", async () => {
99
+ const ctx = (await getQuickJS()).newContext();
100
+ const arena = new Arena(ctx, { isMarshalable: true });
101
+
102
+ class Cls {
103
+ value: number;
104
+
105
+ constructor(a: number, b: number) {
106
+ this.value = a + b;
107
+ }
108
+ }
109
+ arena.expose({ Cls });
110
+
111
+ const instance = arena.evalCode(`new Cls(2, 3)`) as { value: number };
112
+ expect(instance.value).toBe(5);
113
+
114
+ arena.dispose();
115
+ ctx.dispose();
116
+ });
117
+
118
+ test("new on a synced class inside the VM", async () => {
119
+ const ctx = (await getQuickJS()).newContext();
120
+ const arena = new Arena(ctx, { isMarshalable: true });
121
+
122
+ class Cls {
123
+ hoge = "";
124
+
125
+ constructor(v = "foo") {
126
+ this.hoge = v;
127
+ }
128
+ }
129
+ arena.expose({ Cls: arena.sync(Cls) });
130
+
131
+ const instance = arena.evalCode(`new Cls("bar")`) as { hoge: string };
132
+ expect(instance.hoge).toBe("bar");
133
+ expect(arena.evalCode(`new Cls() instanceof Cls`)).toBe(true);
134
+
135
+ arena.dispose();
136
+ ctx.dispose();
137
+ });
138
+ });
139
+
76
140
  describe("evalCode", () => {
77
141
  test("simple object and function", async () => {
78
142
  const ctx = (await getQuickJS()).newContext();
@@ -254,6 +318,36 @@ describe("evalCode", () => {
254
318
  arena.dispose();
255
319
  ctx.dispose();
256
320
  });
321
+
322
+ test("options are forwarded (strict mode)", async () => {
323
+ const ctx = (await getQuickJS()).newContext();
324
+ const arena = new Arena(ctx, { isMarshalable: true });
325
+
326
+ // Assigning to an undeclared variable succeeds in sloppy mode...
327
+ expect(arena.evalCode("x = 1")).toBe(1);
328
+ // ...but throws a ReferenceError in strict mode.
329
+ expect(() => arena.evalCode("y = 1", undefined, { strict: true })).toThrow(ReferenceError);
330
+
331
+ arena.dispose();
332
+ ctx.dispose();
333
+ });
334
+
335
+ test("filename appears in error stacks", async () => {
336
+ const ctx = (await getQuickJS()).newContext();
337
+ const arena = new Arena(ctx, { isMarshalable: true });
338
+
339
+ try {
340
+ arena.evalCode(`throw new Error("boom")`, "my-special-file.js");
341
+ throw new Error("should have thrown");
342
+ } catch (e) {
343
+ expect(e).toBeInstanceOf(Error);
344
+ expect((e as Error).message).toBe("boom");
345
+ expect((e as Error).stack).toContain("my-special-file.js");
346
+ }
347
+
348
+ arena.dispose();
349
+ ctx.dispose();
350
+ });
257
351
  });
258
352
 
259
353
  describe("expose without sync", () => {
@@ -550,6 +644,26 @@ test("register and unregister", async () => {
550
644
  ctx.dispose();
551
645
  });
552
646
 
647
+ test("plain call passes `this` as undefined, method call passes the receiver", async () => {
648
+ const ctx = (await getQuickJS()).newContext();
649
+ const arena = new Arena(ctx, { isMarshalable: true });
650
+
651
+ arena.expose({
652
+ whoAmI() {
653
+ return this;
654
+ },
655
+ });
656
+
657
+ // A plain call would see `this === globalThis` in plain JS; the VM global is
658
+ // intentionally not marshalled to the host, so `this` is undefined here.
659
+ expect(arena.evalCode(`whoAmI()`)).toBe(undefined);
660
+ // A method call still receives its receiver.
661
+ expect(arena.evalCode(`const o = { v: 42, whoAmI }; o.whoAmI().v`)).toBe(42);
662
+
663
+ arena.dispose();
664
+ ctx.dispose();
665
+ });
666
+
553
667
  test("registeredObjects option", async () => {
554
668
  const ctx = (await getQuickJS()).newContext();
555
669
  const arena = new Arena(ctx, {
@@ -816,6 +930,30 @@ describe("evalModule", () => {
816
930
  arena.dispose();
817
931
  ctx.dispose();
818
932
  });
933
+
934
+ test("user options are merged and type is forced to module", async () => {
935
+ const ctx = (await getQuickJS()).newContext();
936
+ const arena = new Arena(ctx, { isMarshalable: true });
937
+
938
+ // `strict` is passed through, and a user-supplied `type` is overridden with
939
+ // "module" so `export` still works and the exports are returned.
940
+ const exports = arena.evalModule(
941
+ `
942
+ export const value = 42;
943
+ export function greet(name) {
944
+ return "Hi, " + name;
945
+ }
946
+ `,
947
+ "with-options.js",
948
+ { strict: true, type: "global" },
949
+ );
950
+
951
+ expect(exports.value).toBe(42);
952
+ expect(exports.greet("World")).toBe("Hi, World");
953
+
954
+ arena.dispose();
955
+ ctx.dispose();
956
+ });
819
957
  });
820
958
 
821
959
  describe("memory management", () => {
@@ -1015,6 +1153,27 @@ describe("memory management", () => {
1015
1153
  arena.dispose();
1016
1154
  ctx.dispose();
1017
1155
  });
1156
+
1157
+ test("setInterruptHandler interrupts infinite loops", async () => {
1158
+ const ctx = (await getQuickJS()).newContext();
1159
+ const arena = new Arena(ctx, { isMarshalable: true });
1160
+
1161
+ // Interrupt after the handler has been called a number of times.
1162
+ let calls = 0;
1163
+ arena.setInterruptHandler(() => ++calls > 1000);
1164
+
1165
+ expect(() => {
1166
+ arena.evalCode(`while (true) {}`);
1167
+ }).toThrow();
1168
+ expect(calls).toBeGreaterThan(1000);
1169
+
1170
+ // After removing the handler, normal evaluation works again.
1171
+ arena.removeInterruptHandler();
1172
+ expect(arena.evalCode(`1 + 2`)).toBe(3);
1173
+
1174
+ arena.dispose();
1175
+ ctx.dispose();
1176
+ });
1018
1177
  });
1019
1178
 
1020
1179
  describe("intrinsics configuration", () => {
@@ -1096,6 +1255,149 @@ describe("AsyncArena", () => {
1096
1255
  });
1097
1256
  });
1098
1257
 
1258
+ describe("asyncify (#32)", () => {
1259
+ test("without the option an async fn yields a Promise in the guest", async () => {
1260
+ const ctx = await newAsyncContext();
1261
+ const arena = new AsyncArena(ctx, { isMarshalable: true });
1262
+
1263
+ arena.expose({ f: async () => "result" });
1264
+ expect(await arena.evalCodeAsync(`typeof f()`)).toBe("object");
1265
+
1266
+ arena.dispose();
1267
+ ctx.dispose();
1268
+ });
1269
+
1270
+ test("asyncify: true resolves the value synchronously in the guest", async () => {
1271
+ const ctx = await newAsyncContext();
1272
+ const arena = new AsyncArena(ctx, { isMarshalable: true, asyncify: true });
1273
+
1274
+ arena.expose({
1275
+ f: async () => {
1276
+ await new Promise(r => setTimeout(r, 1));
1277
+ return "result";
1278
+ },
1279
+ g: async () => ({ a: 1, b: [2, 3] }),
1280
+ });
1281
+
1282
+ expect(await arena.evalCodeAsync(`typeof f()`)).toBe("string");
1283
+ expect(await arena.evalCodeAsync(`f()`)).toBe("result");
1284
+ expect(await arena.evalCodeAsync(`const o = g(); [typeof o, o.a, o.b[1]]`)).toEqual([
1285
+ "object",
1286
+ 1,
1287
+ 3,
1288
+ ]);
1289
+
1290
+ arena.dispose();
1291
+ ctx.dispose();
1292
+ });
1293
+
1294
+ test("asyncify: true passes arguments through", async () => {
1295
+ const ctx = await newAsyncContext();
1296
+ const arena = new AsyncArena(ctx, { isMarshalable: true, asyncify: true });
1297
+
1298
+ arena.expose({ add: async (a: number, b: number) => a + b });
1299
+ expect(await arena.evalCodeAsync(`add(2, 3)`)).toBe(5);
1300
+
1301
+ arena.dispose();
1302
+ ctx.dispose();
1303
+ });
1304
+
1305
+ test("predicate form only asyncifies matching functions", async () => {
1306
+ const ctx = await newAsyncContext();
1307
+ const arena = new AsyncArena(ctx, {
1308
+ isMarshalable: true,
1309
+ asyncify: target => (target as any).asyncified === true,
1310
+ });
1311
+
1312
+ const yes = async () => "sync-value";
1313
+ (yes as any).asyncified = true;
1314
+ const no = async () => "promise-value";
1315
+
1316
+ arena.expose({ yes, no });
1317
+
1318
+ expect(await arena.evalCodeAsync(`typeof yes()`)).toBe("string");
1319
+ expect(await arena.evalCodeAsync(`yes()`)).toBe("sync-value");
1320
+ expect(await arena.evalCodeAsync(`typeof no()`)).toBe("object");
1321
+
1322
+ arena.dispose();
1323
+ ctx.dispose();
1324
+ });
1325
+
1326
+ test("a rejecting asyncified fn surfaces as a catchable VM error", async () => {
1327
+ const ctx = await newAsyncContext();
1328
+ const arena = new AsyncArena(ctx, { isMarshalable: true, asyncify: true });
1329
+
1330
+ arena.expose({
1331
+ boom: async () => {
1332
+ throw new Error("kaboom");
1333
+ },
1334
+ });
1335
+
1336
+ // Uncaught in the guest: the error propagates back to the host.
1337
+ await expect(arena.evalCodeAsync(`boom()`)).rejects.toThrow("kaboom");
1338
+
1339
+ // Caught in the guest: the guest can handle it synchronously.
1340
+ expect(
1341
+ await arena.evalCodeAsync(`
1342
+ try {
1343
+ boom();
1344
+ "no-throw";
1345
+ } catch (e) {
1346
+ "caught:" + e.message;
1347
+ }
1348
+ `),
1349
+ ).toBe("caught:kaboom");
1350
+
1351
+ arena.dispose();
1352
+ ctx.dispose();
1353
+ });
1354
+
1355
+ test("sequential asyncified calls in one evalCodeAsync work", async () => {
1356
+ const ctx = await newAsyncContext();
1357
+ const arena = new AsyncArena(ctx, { isMarshalable: true, asyncify: true });
1358
+
1359
+ arena.expose({
1360
+ first: async () => 10,
1361
+ second: async (n: number) => n + 5,
1362
+ });
1363
+
1364
+ expect(await arena.evalCodeAsync(`const a = first(); const b = second(a); a + b`)).toBe(25);
1365
+
1366
+ arena.dispose();
1367
+ ctx.dispose();
1368
+ });
1369
+
1370
+ test("calling an asyncified fn during synchronous evalCode fails", async () => {
1371
+ const ctx = await newAsyncContext();
1372
+ const arena = new AsyncArena(ctx, { isMarshalable: true, asyncify: true });
1373
+
1374
+ arena.expose({ f: async () => "result" });
1375
+
1376
+ // Asyncify can only suspend through an async entry point; a synchronous
1377
+ // evalCode cannot unwind the stack, so it throws.
1378
+ expect(() => arena.evalCode(`f()`)).toThrow("Function unexpectedly returned a Promise");
1379
+
1380
+ arena.dispose();
1381
+ ctx.dispose();
1382
+ });
1383
+
1384
+ test("sync context fallback: asyncify does not break, async fn behaves as today", async () => {
1385
+ const ctx = (await getQuickJS()).newContext();
1386
+ const arena = new Arena(ctx, { isMarshalable: true, asyncify: true });
1387
+
1388
+ arena.expose({ f: async () => "result" });
1389
+ // No newAsyncifiedFunction on a plain context: falls back to Promise marshalling.
1390
+ expect(arena.evalCode(`globalThis.__p = f(); typeof __p`)).toBe("object");
1391
+ // Let the host promise settle into the VM promise before disposing, so the
1392
+ // marshalled resolution does not touch a disposed context.
1393
+ await new Promise(r => setTimeout(r));
1394
+ arena.executePendingJobs();
1395
+
1396
+ arena.dispose();
1397
+ ctx.dispose();
1398
+ });
1399
+ });
1400
+
1099
1401
  describe("Symbol.dispose", () => {
1100
1402
  test("using disposes the arena", async () => {
1101
1403
  const ctx = (await getQuickJS()).newContext();