@crowdedkingdoms/crowdyjs 8.21.1 → 9.0.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.
Files changed (55) hide show
  1. package/MIGRATION.md +44 -0
  2. package/README.md +74 -3
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/live-coding/assets/browser-authoring-index.json +1 -0
  7. package/dist/live-coding/assets/manifest.json +19 -0
  8. package/dist/live-coding/assets/tree-sitter-rust.wasm +0 -0
  9. package/dist/live-coding/assets/web-tree-sitter.wasm +0 -0
  10. package/dist/live-coding/browser-authoring-index.generated.d.ts +2 -0
  11. package/dist/live-coding/browser-authoring-index.generated.d.ts.map +1 -0
  12. package/dist/live-coding/browser-authoring-index.generated.js +5127 -0
  13. package/dist/live-coding/ide.d.ts +14 -7
  14. package/dist/live-coding/ide.d.ts.map +1 -1
  15. package/dist/live-coding/ide.js +296 -181
  16. package/dist/live-coding/index.d.ts +8 -0
  17. package/dist/live-coding/index.d.ts.map +1 -0
  18. package/dist/live-coding/index.js +7 -0
  19. package/dist/live-coding/lsp-protocol.d.ts +98 -0
  20. package/dist/live-coding/lsp-protocol.d.ts.map +1 -0
  21. package/dist/live-coding/lsp-protocol.js +70 -0
  22. package/dist/live-coding/monaco-services.d.ts +22 -0
  23. package/dist/live-coding/monaco-services.d.ts.map +1 -0
  24. package/dist/live-coding/monaco-services.js +69 -0
  25. package/dist/live-coding/platform-index.d.ts +29 -0
  26. package/dist/live-coding/platform-index.d.ts.map +1 -0
  27. package/dist/live-coding/platform-index.js +281 -0
  28. package/dist/live-coding/rust-analysis.d.ts +24 -0
  29. package/dist/live-coding/rust-analysis.d.ts.map +1 -0
  30. package/dist/live-coding/rust-analysis.js +312 -0
  31. package/dist/live-coding/rust-lsp-server.d.ts +43 -0
  32. package/dist/live-coding/rust-lsp-server.d.ts.map +1 -0
  33. package/dist/live-coding/rust-lsp-server.js +455 -0
  34. package/dist/live-coding/rust-lsp.worker.d.ts +2 -0
  35. package/dist/live-coding/rust-lsp.worker.d.ts.map +1 -0
  36. package/dist/live-coding/rust-lsp.worker.js +26 -0
  37. package/dist/live-coding/vfs.d.ts +41 -0
  38. package/dist/live-coding/vfs.d.ts.map +1 -0
  39. package/dist/live-coding/vfs.js +174 -0
  40. package/dist/live-coding/worker-transport.d.ts +60 -0
  41. package/dist/live-coding/worker-transport.d.ts.map +1 -0
  42. package/dist/live-coding/worker-transport.js +204 -0
  43. package/dist/player-runtime/glue-runtime.d.ts +13 -4
  44. package/dist/player-runtime/glue-runtime.d.ts.map +1 -1
  45. package/dist/player-runtime/glue-runtime.js +52 -8
  46. package/dist/player-runtime/glue-sab.d.ts +8 -5
  47. package/dist/player-runtime/glue-sab.d.ts.map +1 -1
  48. package/dist/player-runtime/glue-sab.js +36 -10
  49. package/dist/player-runtime/player-code-broker.d.ts +33 -4
  50. package/dist/player-runtime/player-code-broker.d.ts.map +1 -1
  51. package/dist/player-runtime/player-code-broker.js +332 -51
  52. package/dist/player-runtime/player-glue-worker.d.ts +3 -2
  53. package/dist/player-runtime/player-glue-worker.d.ts.map +1 -1
  54. package/dist/player-runtime/player-glue-worker.js +68 -32
  55. package/package.json +18 -6
@@ -1,4 +1,4 @@
1
- import { wrapGlueSab, writeGlueResult } from './glue-sab.js';
1
+ import { GLUE_HOST_CALL_TIMEOUT_MS, SAB_HEADER_BYTES, wrapGlueSab, writeGlueResult, } from './glue-sab.js';
2
2
  /**
3
3
  * Deny-by-default host-call allowlist, grouped by capability (04 §4). Only
4
4
  * owner-lawful reads and effects cross the bridge; auth, admin, authoring,
@@ -57,6 +57,22 @@ for (const [group, fns] of Object.entries(ALLOWED_HOST_CALLS)) {
57
57
  }
58
58
  const RATE_WINDOW_MS = 1000;
59
59
  const CIRCUIT_TRIP_THRESHOLD = 5;
60
+ const GLOBAL_HOST_CALL_CAP = 1000;
61
+ const MAX_HOST_CALL_FN_BYTES = 128;
62
+ const MAX_HOST_CALL_ARGS_BYTES = 256 * 1024;
63
+ const HOST_CALL_ENVELOPE_KEYS = new Set([
64
+ 'type',
65
+ 'id',
66
+ 'fn',
67
+ 'args',
68
+ 'reply',
69
+ ]);
70
+ const FORBIDDEN_BINDING_KEYS = new Set([
71
+ 'authority',
72
+ 'bindingKind',
73
+ 'binding_kind',
74
+ ]);
75
+ const utf8Encoder = new TextEncoder();
60
76
  /**
61
77
  * Page-side security broker for browser-target player WASM (production shape,
62
78
  * player compute P3).
@@ -81,44 +97,52 @@ export class PlayerCodeBroker {
81
97
  constructor(options) {
82
98
  this.options = options;
83
99
  this.worker = null;
100
+ this.workerListener = null;
101
+ this.artifact = null;
84
102
  this.circuitOpen = false;
85
103
  this.consecutiveTraps = 0;
104
+ this.hardTimeouts = 0;
86
105
  this.rateBuckets = new Map();
87
- this.onMessage = (event) => {
88
- void this.handleMessage(event.data);
89
- };
106
+ this.globalCallBucket = [];
107
+ this.generation = 0;
108
+ this.lifecycleVersion = 0;
109
+ this.starting = false;
110
+ this.workerReady = false;
111
+ this.startupTimer = null;
112
+ this.dispatchTimer = null;
113
+ this.activeDispatch = null;
90
114
  }
91
115
  /**
92
116
  * Start the worker on a platform-fetched artifact. Verifies the artifact
93
- * hash (side-load refusal, T7) before handing bytes to the worker, and
94
- * forwards the fuel budget so the glue can trap a runaway dispatch.
117
+ * hash (side-load refusal, T7) before handing bytes to the worker. A retained
118
+ * copy allows the page-side hard watchdog to replace a wedged worker.
95
119
  */
96
120
  async start(artifact) {
97
- if (this.worker)
121
+ if (this.worker || this.starting) {
98
122
  throw new Error('PlayerCodeBroker is already started');
123
+ }
99
124
  if (this.circuitOpen) {
100
125
  throw new Error('player code circuit is open; reset before starting');
101
126
  }
102
- if (this.options.artifactHash) {
103
- const actual = await this.hash(artifact);
104
- if (actual !== this.options.artifactHash) {
105
- throw new Error('refusing to run an artifact that was not fetched from the platform');
127
+ const lifecycleVersion = ++this.lifecycleVersion;
128
+ this.starting = true;
129
+ try {
130
+ if (this.options.artifactHash) {
131
+ const actual = await this.hash(artifact);
132
+ if (actual !== this.options.artifactHash) {
133
+ throw new Error('refusing to run an artifact that was not fetched from the platform');
134
+ }
135
+ }
136
+ if (lifecycleVersion !== this.lifecycleVersion) {
137
+ throw new Error('PlayerCodeBroker start was cancelled');
106
138
  }
139
+ this.artifact = artifact.slice(0);
140
+ this.spawnWorker();
141
+ }
142
+ finally {
143
+ if (lifecycleVersion === this.lifecycleVersion)
144
+ this.starting = false;
107
145
  }
108
- const factory = this.options.workerFactory ??
109
- ((url) => new Worker(url, { type: 'module' }));
110
- this.worker = factory(this.options.workerUrl);
111
- this.worker.addEventListener('message', this.onMessage);
112
- this.worker.postMessage({
113
- type: 'init',
114
- artifact,
115
- authority: 'player',
116
- fuelPerDispatch: this.options.fuelPerDispatch != null
117
- ? this.options.fuelPerDispatch.toString()
118
- : undefined,
119
- watchdogMs: this.options.dispatchWatchdogMs ?? 250,
120
- tickIntervalMs: this.options.tickIntervalMs ?? 0,
121
- }, [artifact]);
122
146
  }
123
147
  /** Terminate + respawn on a fresh artifact — the client hot-reload path. */
124
148
  async restart(artifact) {
@@ -126,47 +150,119 @@ export class PlayerCodeBroker {
126
150
  await this.start(artifact);
127
151
  }
128
152
  stop() {
129
- if (!this.worker)
130
- return;
131
- this.worker.removeEventListener('message', this.onMessage);
132
- this.worker.terminate();
133
- this.worker = null;
153
+ this.lifecycleVersion += 1;
154
+ this.starting = false;
155
+ this.stopWorker();
156
+ this.artifact = null;
134
157
  }
135
158
  /** Clear a tripped circuit so the caller can start again after a fix. */
136
159
  resetCircuit() {
137
160
  this.circuitOpen = false;
138
161
  this.consecutiveTraps = 0;
162
+ this.hardTimeouts = 0;
139
163
  this.rateBuckets.clear();
164
+ this.globalCallBucket = [];
140
165
  }
141
- async handleMessage(raw) {
142
- if (!this.worker || !isRecord(raw))
166
+ async handleMessage(raw, generation, sourceWorker) {
167
+ if (this.worker !== sourceWorker ||
168
+ generation !== this.generation ||
169
+ !isRecord(raw)) {
143
170
  return;
171
+ }
172
+ if (raw.type === 'ready') {
173
+ this.workerReady = true;
174
+ this.clearStartupTimer();
175
+ return;
176
+ }
177
+ if (raw.type === 'dispatch-start') {
178
+ if (!Number.isSafeInteger(raw.id) || raw.id <= 0) {
179
+ this.recycleWorker('invalid dispatch-start id');
180
+ return;
181
+ }
182
+ if (this.activeDispatch) {
183
+ this.recycleWorker('overlapping guest dispatches');
184
+ return;
185
+ }
186
+ this.activeDispatch = {
187
+ generation,
188
+ id: raw.id,
189
+ kind: typeof raw.kind === 'string' ? raw.kind : 'unknown',
190
+ };
191
+ this.armDispatchTimer(generation, raw.id);
192
+ return;
193
+ }
144
194
  if (raw.type === 'trap') {
145
- this.recordTrap(typeof raw.reason === 'string' ? raw.reason : 'trap');
195
+ this.finishDispatch(generation, raw.id);
196
+ const reason = typeof raw.reason === 'string' ? raw.reason : 'trap';
197
+ const detail = typeof raw.detail === 'string' ? raw.detail : '';
198
+ const trapKind = typeof raw.kind === 'string' ? raw.kind : '';
199
+ if (reason === 'watchdog' || /host call timed out/i.test(detail)) {
200
+ this.recycleWorker(/host call timed out/i.test(detail) ? 'hostcall-timeout' : reason);
201
+ }
202
+ else if (trapKind === 'startup' || trapKind === 'init') {
203
+ this.stopWorker();
204
+ this.recordTrap(reason);
205
+ }
206
+ else {
207
+ this.recordTrap(reason);
208
+ }
146
209
  return;
147
210
  }
148
211
  if (raw.type === 'dispatch-ok') {
149
- this.consecutiveTraps = 0;
212
+ const kind = this.activeDispatch?.kind;
213
+ this.finishDispatch(generation, raw.id);
214
+ if (kind && kind !== 'init') {
215
+ this.consecutiveTraps = 0;
216
+ this.hardTimeouts = 0;
217
+ }
150
218
  return;
151
219
  }
152
220
  if (raw.type !== 'hostcall')
153
221
  return;
222
+ this.armHostCallTimer(generation);
223
+ if (!this.enforceGlobalRate())
224
+ return;
154
225
  const id = raw.id;
155
226
  try {
156
- if (typeof id !== 'number')
227
+ if (!Number.isSafeInteger(id) || id < 0) {
157
228
  throw new Error('invalid hostcall id');
229
+ }
230
+ for (const key of Object.keys(raw)) {
231
+ if (!HOST_CALL_ENVELOPE_KEYS.has(key)) {
232
+ throw new Error(`unexpected hostcall envelope field '${key}'`);
233
+ }
234
+ }
158
235
  if (typeof raw.fn !== 'string')
159
236
  throw new Error('missing host call fn');
237
+ if (raw.fn.length === 0 ||
238
+ utf8Encoder.encode(raw.fn).length > MAX_HOST_CALL_FN_BYTES) {
239
+ throw new Error('invalid host call fn');
240
+ }
160
241
  const group = FN_TO_GROUP.get(raw.fn);
161
242
  if (!group) {
162
243
  throw new Error('host call is not allowed in the player browser sandbox');
163
244
  }
164
- if (!isRecord(raw.args)) {
245
+ if (!isPlainRecord(raw.args)) {
165
246
  // Confused-deputy guard: reject malformed payloads outright rather
166
247
  // than coercing them (09 T7).
167
- throw new Error('host call args must be an object');
248
+ throw new Error('host call args must be a plain object');
168
249
  }
169
250
  const args = raw.args;
251
+ for (const key of FORBIDDEN_BINDING_KEYS) {
252
+ if (Object.prototype.hasOwnProperty.call(args, key)) {
253
+ throw new Error(`host call cannot override '${key}'`);
254
+ }
255
+ }
256
+ let encodedArgs;
257
+ try {
258
+ encodedArgs = utf8Encoder.encode(JSON.stringify(args));
259
+ }
260
+ catch {
261
+ throw new Error('host call args must be JSON-serializable');
262
+ }
263
+ if (encodedArgs.length > MAX_HOST_CALL_ARGS_BYTES) {
264
+ throw new Error('host call args exceed the browser sandbox limit');
265
+ }
170
266
  this.enforceRate(group, raw.fn);
171
267
  this.assertGridScope(raw.fn, args);
172
268
  let data;
@@ -179,7 +275,12 @@ export class PlayerCodeBroker {
179
275
  low: { x: low.x.toString(), y: low.y.toString(), z: low.z.toString() },
180
276
  high: { x: high.x.toString(), y: high.y.toString(), z: high.z.toString() },
181
277
  };
182
- this.reply(raw.reply, id, { type: 'hostcall-result', id, ok: true, data });
278
+ this.reply(sourceWorker, generation, raw.reply, id, {
279
+ type: 'hostcall-result',
280
+ id,
281
+ ok: true,
282
+ data,
283
+ });
183
284
  return;
184
285
  }
185
286
  if (PRESENTATION_FUNCTIONS.has(raw.fn)) {
@@ -194,10 +295,15 @@ export class PlayerCodeBroker {
194
295
  else {
195
296
  data = await this.options.onHostCall({ fn: raw.fn, args });
196
297
  }
197
- this.reply(raw.reply, id, { type: 'hostcall-result', id, ok: true, data });
298
+ this.reply(sourceWorker, generation, raw.reply, id, {
299
+ type: 'hostcall-result',
300
+ id,
301
+ ok: true,
302
+ data,
303
+ });
198
304
  }
199
305
  catch (error) {
200
- this.reply(raw.reply, id, {
306
+ this.reply(sourceWorker, generation, raw.reply, id, {
201
307
  type: 'hostcall-result',
202
308
  id,
203
309
  ok: false,
@@ -214,15 +320,173 @@ export class PlayerCodeBroker {
214
320
  * runs its message handler), so the SAB write is the load-bearing path in
215
321
  * the browser; the postMessage is harmless there.
216
322
  */
217
- reply(replyBuffer, _id, message) {
218
- this.worker?.postMessage(message);
219
- if (typeof SharedArrayBuffer !== 'undefined' && replyBuffer instanceof SharedArrayBuffer) {
220
- const view = wrapGlueSab(replyBuffer);
221
- const envelope = message.ok
222
- ? { ok: true, data: message.data }
223
- : { ok: false, error: message.error };
224
- writeGlueResult(view, envelope);
323
+ reply(sourceWorker, generation, replyBuffer, id, message) {
324
+ if (this.worker !== sourceWorker || generation !== this.generation)
325
+ return;
326
+ let sabAttempted = false;
327
+ let delivered = false;
328
+ if (typeof SharedArrayBuffer !== 'undefined' &&
329
+ replyBuffer instanceof SharedArrayBuffer &&
330
+ replyBuffer.byteLength >= SAB_HEADER_BYTES) {
331
+ sabAttempted = true;
332
+ try {
333
+ const view = wrapGlueSab(replyBuffer);
334
+ const envelope = message.ok
335
+ ? { ok: true, data: message.data }
336
+ : { ok: false, error: message.error };
337
+ delivered = writeGlueResult(view, envelope, id);
338
+ }
339
+ catch {
340
+ // The async postMessage result above is still delivered. A forged or
341
+ // undersized SAB must not turn a denied worker request into an
342
+ // unhandled page-side rejection.
343
+ }
344
+ }
345
+ if (!sabAttempted || delivered) {
346
+ try {
347
+ sourceWorker.postMessage(message);
348
+ }
349
+ catch {
350
+ // The SAB is authoritative in production. An async mirror that cannot
351
+ // be cloned must not strand a successfully delivered synchronous reply.
352
+ }
225
353
  }
354
+ if (delivered)
355
+ this.resumeDispatchWatchdog(generation);
356
+ }
357
+ spawnWorker() {
358
+ if (!this.artifact || this.circuitOpen)
359
+ return;
360
+ const factory = this.options.workerFactory ??
361
+ ((url) => new Worker(url, { type: 'module' }));
362
+ const worker = factory(this.options.workerUrl);
363
+ const generation = ++this.generation;
364
+ const listener = (event) => {
365
+ void this.handleMessage(event.data, generation, worker);
366
+ };
367
+ this.worker = worker;
368
+ this.workerListener = listener;
369
+ this.workerReady = false;
370
+ worker.addEventListener('message', listener);
371
+ const workerArtifact = this.artifact.slice(0);
372
+ try {
373
+ worker.postMessage({
374
+ type: 'init',
375
+ artifact: workerArtifact,
376
+ authority: 'player',
377
+ fuelPerDispatch: this.options.fuelPerDispatch?.toString(),
378
+ hostCallTimeoutMs: this.options.hostCallTimeoutMs ?? GLUE_HOST_CALL_TIMEOUT_MS,
379
+ tickIntervalMs: this.options.tickIntervalMs ?? 0,
380
+ }, [workerArtifact]);
381
+ this.armStartupTimer(generation);
382
+ }
383
+ catch (error) {
384
+ this.stopWorker();
385
+ throw error;
386
+ }
387
+ }
388
+ stopWorker() {
389
+ this.clearStartupTimer();
390
+ this.clearDispatchTimer();
391
+ this.activeDispatch = null;
392
+ this.workerReady = false;
393
+ const worker = this.worker;
394
+ const listener = this.workerListener;
395
+ this.worker = null;
396
+ this.workerListener = null;
397
+ if (!worker)
398
+ return;
399
+ if (listener)
400
+ worker.removeEventListener('message', listener);
401
+ worker.terminate();
402
+ }
403
+ armStartupTimer(generation) {
404
+ this.clearStartupTimer();
405
+ const timeoutMs = this.options.startupWatchdogMs ??
406
+ Math.max(5000, (this.options.dispatchWatchdogMs ?? 250) * 4);
407
+ this.startupTimer = setTimeout(() => {
408
+ if (generation === this.generation &&
409
+ this.worker &&
410
+ !this.workerReady) {
411
+ this.recycleWorker('startup-watchdog');
412
+ }
413
+ }, timeoutMs);
414
+ }
415
+ clearStartupTimer() {
416
+ if (this.startupTimer)
417
+ clearTimeout(this.startupTimer);
418
+ this.startupTimer = null;
419
+ }
420
+ armDispatchTimer(generation, id) {
421
+ this.clearDispatchTimer();
422
+ this.dispatchTimer = setTimeout(() => {
423
+ if (this.activeDispatch?.generation === generation &&
424
+ this.activeDispatch.id === id) {
425
+ this.recycleWorker('dispatch-watchdog');
426
+ }
427
+ }, this.options.dispatchWatchdogMs ?? 250);
428
+ }
429
+ armHostCallTimer(generation) {
430
+ if (this.activeDispatch?.generation !== generation ||
431
+ !this.worker) {
432
+ return;
433
+ }
434
+ const id = this.activeDispatch.id;
435
+ this.clearDispatchTimer();
436
+ this.dispatchTimer = setTimeout(() => {
437
+ if (this.activeDispatch?.generation === generation &&
438
+ this.activeDispatch.id === id) {
439
+ this.recycleWorker('hostcall-timeout');
440
+ }
441
+ }, this.options.hostCallTimeoutMs ?? GLUE_HOST_CALL_TIMEOUT_MS);
442
+ }
443
+ resumeDispatchWatchdog(generation) {
444
+ if (this.activeDispatch?.generation !== generation)
445
+ return;
446
+ this.armDispatchTimer(generation, this.activeDispatch.id);
447
+ }
448
+ finishDispatch(generation, id) {
449
+ if (!Number.isSafeInteger(id) ||
450
+ this.activeDispatch?.generation !== generation ||
451
+ this.activeDispatch.id !== id) {
452
+ return;
453
+ }
454
+ this.clearDispatchTimer();
455
+ this.activeDispatch = null;
456
+ }
457
+ clearDispatchTimer() {
458
+ if (this.dispatchTimer)
459
+ clearTimeout(this.dispatchTimer);
460
+ this.dispatchTimer = null;
461
+ }
462
+ recycleWorker(reason) {
463
+ if (!this.worker || this.circuitOpen)
464
+ return;
465
+ this.hardTimeouts += 1;
466
+ this.consecutiveTraps += 1;
467
+ this.stopWorker();
468
+ if (this.hardTimeouts >= CIRCUIT_TRIP_THRESHOLD) {
469
+ this.openCircuit(reason);
470
+ return;
471
+ }
472
+ this.spawnWorker();
473
+ }
474
+ openCircuit(reason) {
475
+ if (this.circuitOpen)
476
+ return;
477
+ this.circuitOpen = true;
478
+ this.stopWorker();
479
+ this.options.onCircuitOpen?.(reason);
480
+ }
481
+ enforceGlobalRate() {
482
+ const now = Date.now();
483
+ this.globalCallBucket = this.globalCallBucket.filter((timestamp) => now - timestamp < RATE_WINDOW_MS);
484
+ if (this.globalCallBucket.length >= GLOBAL_HOST_CALL_CAP) {
485
+ this.openCircuit('global host-call rate exceeded');
486
+ return false;
487
+ }
488
+ this.globalCallBucket.push(now);
489
+ return true;
226
490
  }
227
491
  enforceRate(group, fn) {
228
492
  const cap = RATE_CAPS[group] ?? 60;
@@ -237,9 +501,7 @@ export class PlayerCodeBroker {
237
501
  recordTrap(reason) {
238
502
  this.consecutiveTraps += 1;
239
503
  if (this.consecutiveTraps >= CIRCUIT_TRIP_THRESHOLD && !this.circuitOpen) {
240
- this.circuitOpen = true;
241
- this.stop();
242
- this.options.onCircuitOpen?.(reason);
504
+ this.openCircuit(reason);
243
505
  }
244
506
  }
245
507
  assertGridScope(fn, args) {
@@ -276,7 +538,26 @@ export class PlayerCodeBroker {
276
538
  function isRecord(value) {
277
539
  return value !== null && typeof value === 'object' && !Array.isArray(value);
278
540
  }
541
+ function isPlainRecord(value) {
542
+ if (!isRecord(value))
543
+ return false;
544
+ const prototype = Object.getPrototypeOf(value);
545
+ return prototype === Object.prototype || prototype === null;
546
+ }
279
547
  function toBigInt(value) {
548
+ if (typeof value === 'string' &&
549
+ !/^-?(0|[1-9][0-9]*)$/.test(value)) {
550
+ throw new Error('chunk coordinates must be decimal integer strings');
551
+ }
552
+ if (typeof value === 'number' &&
553
+ (!Number.isSafeInteger(value) || !Number.isFinite(value))) {
554
+ throw new Error('chunk coordinates must be safe integers or decimal strings');
555
+ }
556
+ if (typeof value !== 'string' &&
557
+ typeof value !== 'number' &&
558
+ typeof value !== 'bigint') {
559
+ throw new Error('chunk coordinates must be integers');
560
+ }
280
561
  try {
281
562
  return BigInt(value);
282
563
  }
@@ -13,8 +13,9 @@
13
13
  * - `ck.host_call` blocks the worker on a SharedArrayBuffer while the
14
14
  * page-side broker services the (async) server-authorized call and writes
15
15
  * the reply back, so the guest sees a synchronous gateway,
16
- * - it enforces a per-dispatch wall-clock watchdog and reports traps to the
17
- * broker, which owns the local circuit breaker.
16
+ * - it announces each dispatch before entering WASM; the page-side broker
17
+ * owns the hard watchdog and can terminate this worker if WASM never
18
+ * returns. The local elapsed-time helper only classifies completed calls.
18
19
  *
19
20
  * Re-exports the pure helpers + the runtime so tests and bundlers can use
20
21
  * them without touching worker globals.
@@ -1 +1 @@
1
- {"version":3,"file":"player-glue-worker.d.ts","sourceRoot":"","sources":["../../src/player-runtime/player-glue-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACxB,MAAM,mBAAmB,CAAC;AAQ3B,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,kBAAkB,GACxB,CAAC;AAOF;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,gBAAgB,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,KAAK,IAAI,CAAC;IACrE,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;IACrD,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACzC,GAAG,IAAI,CAuGP"}
1
+ {"version":3,"file":"player-glue-worker.d.ts","sourceRoot":"","sources":["../../src/player-runtime/player-glue-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACxB,MAAM,mBAAmB,CAAC;AAS3B,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,kBAAkB,GACxB,CAAC;AAOF;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,gBAAgB,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,KAAK,IAAI,CAAC;IACrE,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;IACrD,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACzC,GAAG,IAAI,CAkJP"}
@@ -13,14 +13,15 @@
13
13
  * - `ck.host_call` blocks the worker on a SharedArrayBuffer while the
14
14
  * page-side broker services the (async) server-authorized call and writes
15
15
  * the reply back, so the guest sees a synchronous gateway,
16
- * - it enforces a per-dispatch wall-clock watchdog and reports traps to the
17
- * broker, which owns the local circuit breaker.
16
+ * - it announces each dispatch before entering WASM; the page-side broker
17
+ * owns the hard watchdog and can terminate this worker if WASM never
18
+ * returns. The local elapsed-time helper only classifies completed calls.
18
19
  *
19
20
  * Re-exports the pure helpers + the runtime so tests and bundlers can use
20
21
  * them without touching worker globals.
21
22
  */
22
23
  import { GlueRuntime, GLUE_HOST_FUNCTIONS, parseFuelBudget, runWithWatchdog, } from './glue-runtime.js';
23
- import { createGlueSab, armGlueRequest, waitAndReadGlueReply, } from './glue-sab.js';
24
+ import { createGlueSab, armGlueRequest, GLUE_HOST_CALL_TIMEOUT_MS, waitAndReadGlueReply, } from './glue-sab.js';
24
25
  export { GlueRuntime, GLUE_HOST_FUNCTIONS, parseFuelBudget, runWithWatchdog, };
25
26
  /**
26
27
  * Wire the glue runtime to a worker-like message port. Exported (not just
@@ -31,7 +32,8 @@ export function startGlueWorker(port) {
31
32
  let runtime = null;
32
33
  let sab = null;
33
34
  let hostCallId = 0;
34
- let watchdogMs = 250;
35
+ let dispatchId = 0;
36
+ let hostCallTimeoutMs = GLUE_HOST_CALL_TIMEOUT_MS;
35
37
  let ticking = false;
36
38
  const post = (message) => port.postMessage(message);
37
39
  // Synchronous gateway: post the request (so the page can see it), then
@@ -46,8 +48,9 @@ export function startGlueWorker(port) {
46
48
  catch {
47
49
  throw new Error('host_call request was not valid JSON');
48
50
  }
49
- const id = ++hostCallId;
50
- armGlueRequest(sab);
51
+ hostCallId = (hostCallId % 0x7ffffffe) + 1;
52
+ const id = hostCallId;
53
+ armGlueRequest(sab, id);
51
54
  post({
52
55
  type: 'hostcall',
53
56
  id,
@@ -55,34 +58,59 @@ export function startGlueWorker(port) {
55
58
  args: parsed.args ?? {},
56
59
  reply: sab.sab,
57
60
  });
58
- return waitAndReadGlueReply(sab);
61
+ return waitAndReadGlueReply(sab, id, hostCallTimeoutMs);
59
62
  };
63
+ const beginDispatch = (kind) => {
64
+ dispatchId = (dispatchId % 0x7ffffffe) + 1;
65
+ post({ type: 'dispatch-start', id: dispatchId, kind });
66
+ return dispatchId;
67
+ };
68
+ const report = (id, kind, result) => {
69
+ if (result.ok)
70
+ post({ type: 'dispatch-ok', id, kind });
71
+ else {
72
+ post({
73
+ type: 'trap',
74
+ id,
75
+ kind,
76
+ reason: result.reason,
77
+ detail: result.detail,
78
+ });
79
+ }
80
+ };
81
+ // Trap classification remains local, but wall-clock enforcement is entirely
82
+ // page-side so time blocked on an authorized async host call is excluded.
83
+ const runDispatch = (dispatch) => runWithWatchdog(dispatch, Number.POSITIVE_INFINITY);
60
84
  const onInit = async (init) => {
61
- watchdogMs = init.watchdogMs ?? 250;
62
- void parseFuelBudget(init.fuelPerDispatch);
85
+ hostCallTimeoutMs =
86
+ init.hostCallTimeoutMs ?? GLUE_HOST_CALL_TIMEOUT_MS;
63
87
  sab = createGlueSab();
88
+ const fuelPerDispatch = parseFuelBudget(init.fuelPerDispatch);
64
89
  runtime = new GlueRuntime({
65
90
  hostCallSync,
66
91
  onLog: (level, message) => post({ type: 'log', level, message }),
92
+ fuelPerDispatch,
67
93
  });
68
94
  try {
69
95
  await runtime.instantiate(init.artifact);
70
- const initResult = await runWithWatchdog(() => runtime.init(), watchdogMs);
71
- report(initResult);
96
+ const id = beginDispatch('init');
97
+ const initResult = await runDispatch(() => runtime.init());
98
+ report(id, 'init', initResult);
99
+ if (!initResult.ok)
100
+ return;
72
101
  if ((init.tickIntervalMs ?? 0) > 0)
73
102
  startTicking(init.tickIntervalMs);
74
103
  post({ type: 'ready' });
75
104
  }
76
105
  catch (err) {
77
- post({ type: 'trap', reason: 'trap', detail: err.message });
106
+ post({
107
+ type: 'trap',
108
+ kind: 'startup',
109
+ reason: 'trap',
110
+ detail: err.message,
111
+ });
78
112
  }
79
113
  };
80
- const report = (result) => {
81
- if (result.ok)
82
- post({ type: 'dispatch-ok' });
83
- else
84
- post({ type: 'trap', reason: result.reason, detail: result.detail });
85
- };
86
114
  const startTicking = (intervalMs) => {
87
115
  if (ticking)
88
116
  return;
@@ -94,7 +122,8 @@ export function startGlueWorker(port) {
94
122
  const nowT = Date.now();
95
123
  const dt = nowT - last;
96
124
  last = nowT;
97
- void runWithWatchdog(() => runtime.tick(dt), watchdogMs).then(report);
125
+ const id = beginDispatch('tick');
126
+ void runDispatch(() => runtime.tick(dt)).then((result) => report(id, 'tick', result));
98
127
  setTimeout(loop, intervalMs);
99
128
  };
100
129
  setTimeout(loop, intervalMs);
@@ -106,22 +135,29 @@ export function startGlueWorker(port) {
106
135
  if (msg.type === 'init')
107
136
  void onInit(msg);
108
137
  else if (msg.type === 'tick' && runtime) {
109
- void runWithWatchdog(() => runtime.tick(typeof msg.dtMs === 'number' ? msg.dtMs : 0), watchdogMs).then(report);
138
+ const id = beginDispatch('tick');
139
+ void runDispatch(() => runtime.tick(typeof msg.dtMs === 'number' ? msg.dtMs : 0)).then((result) => report(id, 'tick', result));
110
140
  }
111
141
  else if (msg.type === 'invoke' && runtime) {
112
142
  const payload = msg.payload instanceof Uint8Array ? msg.payload : new Uint8Array(0);
113
- try {
114
- const out = runtime.invoke(payload);
115
- post({ type: 'invoke-result', id: msg.id, ok: true, payload: out });
116
- }
117
- catch (err) {
118
- post({
119
- type: 'invoke-result',
120
- id: msg.id,
121
- ok: false,
122
- error: err.message,
123
- });
124
- }
143
+ const id = beginDispatch('invoke');
144
+ let out = new Uint8Array(0);
145
+ void runDispatch(() => {
146
+ out = runtime.invoke(payload);
147
+ }).then((result) => {
148
+ report(id, 'invoke', result);
149
+ if (result.ok) {
150
+ post({ type: 'invoke-result', id: msg.id, ok: true, payload: out });
151
+ }
152
+ else {
153
+ post({
154
+ type: 'invoke-result',
155
+ id: msg.id,
156
+ ok: false,
157
+ error: result.detail ?? result.reason,
158
+ });
159
+ }
160
+ });
125
161
  }
126
162
  else if (msg.type === 'stop') {
127
163
  ticking = false;