@crowdedkingdoms/crowdyjs 8.21.1 → 8.22.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/dist/index.d.ts CHANGED
@@ -45,7 +45,7 @@
45
45
  * or internal tooling, never an untrusted browser.
46
46
  */
47
47
  /** The published package version. Mirrors `package.json`. */
48
- export declare const VERSION = "8.21.1";
48
+ export declare const VERSION = "8.22.0";
49
49
  export { LbCookieStore } from './lb-cookie-store.js';
50
50
  export { CrowdyClient, createCrowdyClient, type CrowdyClientConfig, } from './crowdy-client.js';
51
51
  export { BrowserLocalStorageTokenStore, SessionStore, type SessionListener, type TokenStore, } from './session.js';
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@
45
45
  * or internal tooling, never an untrusted browser.
46
46
  */
47
47
  /** The published package version. Mirrors `package.json`. */
48
- export const VERSION = '8.21.1';
48
+ export const VERSION = '8.22.0';
49
49
  export { LbCookieStore } from './lb-cookie-store.js';
50
50
  export { CrowdyClient, createCrowdyClient, } from './crowdy-client.js';
51
51
  export { BrowserLocalStorageTokenStore, SessionStore, } from './session.js';
@@ -25,12 +25,18 @@ export interface GlueInitMessage {
25
25
  type: 'init';
26
26
  artifact: ArrayBuffer;
27
27
  authority: 'player';
28
+ /** Server-authored budget loaded into an injected mutable `ck_fuel` global. */
28
29
  fuelPerDispatch?: string;
30
+ /** Legacy metadata; the hard watchdog is owned by the page-side broker. */
29
31
  watchdogMs?: number;
32
+ hostCallTimeoutMs?: number;
30
33
  /** Local client tick cadence in ms (0/undefined => no self-tick). */
31
34
  tickIntervalMs?: number;
32
35
  }
33
- /** Parse the fuel budget the broker forwards; undefined/invalid => unbounded (server still meters). */
36
+ /**
37
+ * Parse the server-authored budget used to refill an instrumented artifact's
38
+ * mutable `ck_fuel` global before every guest dispatch.
39
+ */
34
40
  export declare function parseFuelBudget(raw: string | undefined): bigint | null;
35
41
  /** A dispatch outcome the worker reports back to the broker. */
36
42
  export type GlueDispatchResult = {
@@ -41,9 +47,9 @@ export type GlueDispatchResult = {
41
47
  detail?: string;
42
48
  };
43
49
  /**
44
- * Wrap a single guest dispatch with the wall-clock watchdog. The fuel trap is
45
- * enforced inside the gas-injected module; this guards against a hang that
46
- * spins without consuming fuel. Pure and unit-testable.
50
+ * Classify a completed guest dispatch by elapsed wall time. This cannot
51
+ * interrupt synchronous WASM; the page-side PlayerCodeBroker owns the hard
52
+ * dispatch watchdog and terminates a worker whose dispatch never returns.
47
53
  */
48
54
  export declare function runWithWatchdog(dispatch: () => unknown, watchdogMs: number, now?: () => number): Promise<GlueDispatchResult>;
49
55
  /** The minimal guest-instance surface the runtime drives (a real WebAssembly.Instance satisfies it). */
@@ -51,6 +57,7 @@ export interface GuestExports {
51
57
  memory: {
52
58
  buffer: ArrayBuffer;
53
59
  };
60
+ ck_fuel?: WebAssembly.Global;
54
61
  ck_alloc(len: number): number;
55
62
  ck_free?(ptr: number, len: number): void;
56
63
  init?(): void;
@@ -66,6 +73,7 @@ export interface GlueRuntimeOptions {
66
73
  /** Deterministic-enough randomness for the guest `random_get` (defaults to crypto). */
67
74
  randomFill?: (buf: Uint8Array) => void;
68
75
  now?: () => number;
76
+ fuelPerDispatch?: bigint | null;
69
77
  }
70
78
  /**
71
79
  * Drives one untrusted guest module: builds the `ck` + wasi import table,
@@ -79,6 +87,7 @@ export declare class GlueRuntime {
79
87
  private exports;
80
88
  private stateBlob;
81
89
  constructor(options: GlueRuntimeOptions);
90
+ private resetFuel;
82
91
  /** The import object handed to `WebAssembly.instantiate`. Guest sees only these. */
83
92
  buildImports(getExports: () => GuestExports | null): WebAssembly.Imports;
84
93
  instantiate(artifact: ArrayBuffer): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"glue-runtime.d.ts","sourceRoot":"","sources":["../../src/player-runtime/glue-runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB,iVAoBtB,CAAC;AAEX,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,WAAW,CAAC;IACtB,SAAS,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,uGAAuG;AACvG,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAQtE;AAED,gEAAgE;AAChE,MAAM,MAAM,kBAAkB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzE;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,OAAO,EACvB,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAM,MAAyB,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAe7B;AAED,wGAAwG;AACxG,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE;QAAE,MAAM,EAAE,WAAW,CAAA;KAAE,CAAC;IAChC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,OAAO,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,IAAI,CAAC,IAAI,IAAI,CAAC;IACd,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1D,QAAQ,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,kBAAkB;IACjC,4FAA4F;IAC5F,YAAY,EAAE,CAAC,QAAQ,EAAE,UAAU,KAAK,UAAU,CAAC;IACnD,gEAAgE;IAChE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,uFAAuF;IACvF,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,KAAK,IAAI,CAAC;IACvC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAgBD;;;;;;GAMG;AACH,qBAAa,WAAW;IAIV,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,SAAS,CAAiC;gBAErB,OAAO,EAAE,kBAAkB;IAExD,oFAAoF;IACpF,YAAY,CAAC,UAAU,EAAE,MAAM,YAAY,GAAG,IAAI,GAAG,WAAW,CAAC,OAAO;IAkFlE,WAAW,CAAC,QAAQ,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAUvD,gEAAgE;IAChE,IAAI,IAAI,IAAI;IAIZ,gFAAgF;IAChF,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIxB,sFAAsF;IACtF,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,UAAU;CAcxC"}
1
+ {"version":3,"file":"glue-runtime.d.ts","sourceRoot":"","sources":["../../src/player-runtime/glue-runtime.ts"],"names":[],"mappings":"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"}
@@ -41,7 +41,10 @@ export const GLUE_HOST_FUNCTIONS = [
41
41
  'overlay_draw',
42
42
  'grid_permission_check',
43
43
  ];
44
- /** Parse the fuel budget the broker forwards; undefined/invalid => unbounded (server still meters). */
44
+ /**
45
+ * Parse the server-authored budget used to refill an instrumented artifact's
46
+ * mutable `ck_fuel` global before every guest dispatch.
47
+ */
45
48
  export function parseFuelBudget(raw) {
46
49
  if (raw == null)
47
50
  return null;
@@ -54,9 +57,9 @@ export function parseFuelBudget(raw) {
54
57
  }
55
58
  }
56
59
  /**
57
- * Wrap a single guest dispatch with the wall-clock watchdog. The fuel trap is
58
- * enforced inside the gas-injected module; this guards against a hang that
59
- * spins without consuming fuel. Pure and unit-testable.
60
+ * Classify a completed guest dispatch by elapsed wall time. This cannot
61
+ * interrupt synchronous WASM; the page-side PlayerCodeBroker owns the hard
62
+ * dispatch watchdog and terminates a worker whose dispatch never returns.
60
63
  */
61
64
  export async function runWithWatchdog(dispatch, watchdogMs, now = () => Date.now()) {
62
65
  const start = now();
@@ -76,6 +79,16 @@ export async function runWithWatchdog(dispatch, watchdogMs, now = () => Date.now
76
79
  return { ok: true };
77
80
  }
78
81
  const textDecoder = new TextDecoder();
82
+ function assertMemoryRange(buffer, ptr, len, operation) {
83
+ if (!Number.isSafeInteger(ptr) ||
84
+ !Number.isSafeInteger(len) ||
85
+ ptr < 0 ||
86
+ len < 0 ||
87
+ ptr > buffer.byteLength ||
88
+ len > buffer.byteLength - ptr) {
89
+ throw new RangeError(`${operation} is outside guest memory`);
90
+ }
91
+ }
79
92
  function defaultRandomFill(buf) {
80
93
  const c = globalThis.crypto;
81
94
  if (c?.getRandomValues) {
@@ -102,6 +115,15 @@ export class GlueRuntime {
102
115
  this.exports = null;
103
116
  this.stateBlob = new Uint8Array(0);
104
117
  }
118
+ resetFuel() {
119
+ const fuel = this.exports?.ck_fuel;
120
+ if (!fuel)
121
+ return;
122
+ if (this.options.fuelPerDispatch == null) {
123
+ throw new Error('instrumented artifact is missing a fuel budget');
124
+ }
125
+ fuel.value = this.options.fuelPerDispatch;
126
+ }
105
127
  /** The import object handed to `WebAssembly.instantiate`. Guest sees only these. */
106
128
  buildImports(getExports) {
107
129
  const mem = () => {
@@ -114,17 +136,21 @@ export class GlueRuntime {
114
136
  const ex = getExports();
115
137
  if (!ex)
116
138
  throw new Error('guest not instantiated');
139
+ const buffer = ex.memory.buffer;
140
+ assertMemoryRange(buffer, ptr, len, 'guest memory read');
117
141
  // Copy into a fresh ArrayBuffer-backed view — the guest buffer may
118
142
  // detach/grow between calls (and may be a SharedArrayBuffer).
119
143
  const out = new Uint8Array(len);
120
- out.set(new Uint8Array(ex.memory.buffer, ptr, len));
144
+ out.set(new Uint8Array(buffer, ptr, len));
121
145
  return out;
122
146
  };
123
147
  const writeAt = (ptr, src) => {
124
148
  const ex = getExports();
125
149
  if (!ex)
126
150
  throw new Error('guest not instantiated');
127
- new Uint8Array(ex.memory.buffer, ptr, src.length).set(src);
151
+ const buffer = ex.memory.buffer;
152
+ assertMemoryRange(buffer, ptr, src.length, 'guest memory write');
153
+ new Uint8Array(buffer, ptr, src.length).set(src);
128
154
  };
129
155
  const now = this.options.now ?? (() => Date.now());
130
156
  const randomFill = this.options.randomFill ?? defaultRandomFill;
@@ -153,6 +179,9 @@ export class GlueRuntime {
153
179
  if (!ex)
154
180
  throw new Error('guest not instantiated');
155
181
  const outPtr = ex.ck_alloc(respBytes.length);
182
+ if (respBytes.length > 0 && outPtr === 0) {
183
+ throw new RangeError('ck_alloc returned a null reply pointer');
184
+ }
156
185
  writeAt(outPtr, respBytes);
157
186
  // Packed (ptr << 32 | len); the guest reads then ck_frees it.
158
187
  return (BigInt(outPtr) << 32n) | BigInt(respBytes.length >>> 0);
@@ -160,9 +189,13 @@ export class GlueRuntime {
160
189
  };
161
190
  const wasi = {
162
191
  random_get: (ptr, len) => {
163
- const buf = new Uint8Array(len);
192
+ const ex = getExports();
193
+ if (!ex)
194
+ throw new Error('guest not instantiated');
195
+ const buffer = ex.memory.buffer;
196
+ assertMemoryRange(buffer, ptr, len, 'random_get write');
197
+ const buf = new Uint8Array(buffer, ptr, len);
164
198
  randomFill(buf);
165
- writeAt(ptr, buf);
166
199
  return 0;
167
200
  },
168
201
  // A player artifact may pull in a few benign wasi stubs; keep them inert.
@@ -196,10 +229,12 @@ export class GlueRuntime {
196
229
  }
197
230
  /** Run the module's `init` export (once, after instantiate). */
198
231
  init() {
232
+ this.resetFuel();
199
233
  this.exports?.init?.();
200
234
  }
201
235
  /** Run one `tick(dt_ms)`. Throws propagate to the caller's watchdog wrapper. */
202
236
  tick(dtMs) {
237
+ this.resetFuel();
203
238
  this.exports?.tick?.(dtMs);
204
239
  }
205
240
  /** Invoke the module with an opaque payload; returns the reply bytes (copied out). */
@@ -208,13 +243,22 @@ export class GlueRuntime {
208
243
  if (!ex || typeof ex.handle_invoke !== 'function')
209
244
  return new Uint8Array(0);
210
245
  const ptr = ex.ck_alloc(payload.length);
246
+ if (payload.length > 0 && ptr === 0) {
247
+ throw new RangeError('ck_alloc returned a null invoke pointer');
248
+ }
249
+ assertMemoryRange(ex.memory.buffer, ptr, payload.length, 'invoke request write');
211
250
  new Uint8Array(ex.memory.buffer, ptr, payload.length).set(payload);
251
+ this.resetFuel();
212
252
  const packed = BigInt(ex.handle_invoke(ptr, payload.length));
213
253
  ex.ck_free?.(ptr, payload.length);
214
254
  const outPtr = Number(packed >> 32n);
215
255
  const outLen = Number(packed & 0xffffffffn);
216
256
  if (outLen === 0)
217
257
  return new Uint8Array(0);
258
+ if (outPtr === 0) {
259
+ throw new RangeError('guest returned a null invoke reply pointer');
260
+ }
261
+ assertMemoryRange(ex.memory.buffer, outPtr, outLen, 'invoke reply read');
218
262
  const out = new Uint8Array(ex.memory.buffer, outPtr, outLen).slice();
219
263
  ex.ck_free?.(outPtr, outLen);
220
264
  return out;
@@ -14,7 +14,9 @@
14
14
  * (COOP: same-origin + COEP: require-corp) in the browser; Node worker
15
15
  * threads have them unconditionally, which is how this is integration-tested.
16
16
  *
17
- * Layout: [ state:i32, len:i32 ] header, then a byte data region.
17
+ * Layout: [ state:i32, len:i32, requestId:i32 ] header, then a byte data
18
+ * region. The request id prevents a host response that completed after the
19
+ * worker's timeout from waking a later request that reused the same SAB.
18
20
  * state: 0 IDLE, 1 PENDING (worker waiting), 2 DONE.
19
21
  * The request travels to the page as a normal postMessage (before the
20
22
  * worker blocks); only the reply uses the SAB.
@@ -22,6 +24,7 @@
22
24
  export declare const SAB_STATE_IDLE = 0;
23
25
  export declare const SAB_STATE_PENDING = 1;
24
26
  export declare const SAB_STATE_DONE = 2;
27
+ export declare const GLUE_HOST_CALL_TIMEOUT_MS = 5000;
25
28
  export declare const SAB_HEADER_BYTES: number;
26
29
  /** 1 MiB reply region — host-call replies (chunk/actor reads) are well under this. */
27
30
  export declare const SAB_DATA_BYTES: number;
@@ -39,9 +42,9 @@ declare const decoder: TextDecoder;
39
42
  * and wake the blocked worker. `respBytes` must already be the SDK Response
40
43
  * envelope (`{ok,data}` / `{ok:false,error}`) the guest expects.
41
44
  */
42
- export declare function writeGlueReply(view: GlueSab, respBytes: Uint8Array): void;
45
+ export declare function writeGlueReply(view: GlueSab, respBytes: Uint8Array, requestId?: number): boolean;
43
46
  /** Convenience for responders holding a plain object result. */
44
- export declare function writeGlueResult(view: GlueSab, result: unknown): void;
47
+ export declare function writeGlueResult(view: GlueSab, result: unknown, requestId?: number): boolean;
45
48
  /**
46
49
  * Requester side (worker): mark PENDING before posting the wake, block until
47
50
  * the page flips the state to DONE, then read the reply bytes out. The
@@ -49,7 +52,7 @@ export declare function writeGlueResult(view: GlueSab, result: unknown): void;
49
52
  * `waitAndRead()` — that ordering (post, then wait) is what lets the page
50
53
  * see the request while the worker is blocked.
51
54
  */
52
- export declare function armGlueRequest(view: GlueSab): void;
53
- export declare function waitAndReadGlueReply(view: GlueSab, timeoutMs?: number): Uint8Array;
55
+ export declare function armGlueRequest(view: GlueSab, requestId: number): void;
56
+ export declare function waitAndReadGlueReply(view: GlueSab, requestId: number, timeoutMs?: number): Uint8Array;
54
57
  export { encoder as glueEncoder, decoder as glueDecoder };
55
58
  //# sourceMappingURL=glue-sab.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"glue-sab.d.ts","sourceRoot":"","sources":["../../src/player-runtime/glue-sab.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,eAAO,MAAM,cAAc,IAAI,CAAC;AAGhC,eAAO,MAAM,gBAAgB,QAAiB,CAAC;AAC/C,sFAAsF;AACtF,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,iBAAiB,CAAC;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,aAAa,CAAC,SAAS,SAAiB,GAAG,OAAO,CAGjE;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAM3D;AAED,QAAA,MAAM,OAAO,aAAoB,CAAC;AAClC,QAAA,MAAM,OAAO,aAAoB,CAAC;AAElC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,GAAG,IAAI,CAMzE;AAED,gEAAgE;AAChE,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAEpE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAGlD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,SAAO,GAAG,UAAU,CAShF;AAED,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,OAAO,IAAI,WAAW,EAAE,CAAC"}
1
+ {"version":3,"file":"glue-sab.d.ts","sourceRoot":"","sources":["../../src/player-runtime/glue-sab.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAG9C,eAAO,MAAM,gBAAgB,QAAiB,CAAC;AAC/C,sFAAsF;AACtF,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,iBAAiB,CAAC;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,aAAa,CAAC,SAAS,SAAiB,GAAG,OAAO,CAGjE;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAM3D;AAED,QAAA,MAAM,OAAO,aAAoB,CAAC;AAClC,QAAA,MAAM,OAAO,aAAoB,CAAC;AAQlC;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,OAAO,EACb,SAAS,EAAE,UAAU,EACrB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAkBT;AAED,gEAAgE;AAChE,wBAAgB,eAAe,CAC7B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,OAAO,EACf,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAET;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAIrE;AAED,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,OAAO,EACb,SAAS,EAAE,MAAM,EACjB,SAAS,SAA4B,GACpC,UAAU,CAuBZ;AAED,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,OAAO,IAAI,WAAW,EAAE,CAAC"}
@@ -14,7 +14,9 @@
14
14
  * (COOP: same-origin + COEP: require-corp) in the browser; Node worker
15
15
  * threads have them unconditionally, which is how this is integration-tested.
16
16
  *
17
- * Layout: [ state:i32, len:i32 ] header, then a byte data region.
17
+ * Layout: [ state:i32, len:i32, requestId:i32 ] header, then a byte data
18
+ * region. The request id prevents a host response that completed after the
19
+ * worker's timeout from waking a later request that reused the same SAB.
18
20
  * state: 0 IDLE, 1 PENDING (worker waiting), 2 DONE.
19
21
  * The request travels to the page as a normal postMessage (before the
20
22
  * worker blocks); only the reply uses the SAB.
@@ -22,7 +24,8 @@
22
24
  export const SAB_STATE_IDLE = 0;
23
25
  export const SAB_STATE_PENDING = 1;
24
26
  export const SAB_STATE_DONE = 2;
25
- const HEADER_I32 = 2; // state, len
27
+ export const GLUE_HOST_CALL_TIMEOUT_MS = 5000;
28
+ const HEADER_I32 = 3; // state, len, request id
26
29
  export const SAB_HEADER_BYTES = HEADER_I32 * 4;
27
30
  /** 1 MiB reply region — host-call replies (chunk/actor reads) are well under this. */
28
31
  export const SAB_DATA_BYTES = 1024 * 1024;
@@ -39,21 +42,34 @@ export function wrapGlueSab(sab) {
39
42
  }
40
43
  const encoder = new TextEncoder();
41
44
  const decoder = new TextDecoder();
45
+ const overflowReply = encoder.encode(JSON.stringify({
46
+ ok: false,
47
+ error: { kind: 'response_too_large', message: 'host response exceeds reply limit' },
48
+ }));
42
49
  /**
43
50
  * Responder side (page/broker): write the reply envelope bytes into the SAB
44
51
  * and wake the blocked worker. `respBytes` must already be the SDK Response
45
52
  * envelope (`{ok,data}` / `{ok:false,error}`) the guest expects.
46
53
  */
47
- export function writeGlueReply(view, respBytes) {
48
- const len = Math.min(respBytes.length, view.data.length);
49
- view.data.set(respBytes.subarray(0, len));
50
- Atomics.store(view.header, 1, len);
54
+ export function writeGlueReply(view, respBytes, requestId) {
55
+ if (requestId != null &&
56
+ (Atomics.load(view.header, 0) !== SAB_STATE_PENDING ||
57
+ Atomics.load(view.header, 2) !== requestId)) {
58
+ return false;
59
+ }
60
+ const payload = respBytes.length <= view.data.length ? respBytes : overflowReply;
61
+ if (payload.length > view.data.length) {
62
+ throw new RangeError('glue reply region cannot hold the overflow envelope');
63
+ }
64
+ view.data.set(payload);
65
+ Atomics.store(view.header, 1, payload.length);
51
66
  Atomics.store(view.header, 0, SAB_STATE_DONE);
52
67
  Atomics.notify(view.header, 0, 1);
68
+ return true;
53
69
  }
54
70
  /** Convenience for responders holding a plain object result. */
55
- export function writeGlueResult(view, result) {
56
- writeGlueReply(view, encoder.encode(JSON.stringify(result)));
71
+ export function writeGlueResult(view, result, requestId) {
72
+ return writeGlueReply(view, encoder.encode(JSON.stringify(result)), requestId);
57
73
  }
58
74
  /**
59
75
  * Requester side (worker): mark PENDING before posting the wake, block until
@@ -62,16 +78,26 @@ export function writeGlueResult(view, result) {
62
78
  * `waitAndRead()` — that ordering (post, then wait) is what lets the page
63
79
  * see the request while the worker is blocked.
64
80
  */
65
- export function armGlueRequest(view) {
81
+ export function armGlueRequest(view, requestId) {
66
82
  Atomics.store(view.header, 1, 0);
83
+ Atomics.store(view.header, 2, requestId);
67
84
  Atomics.store(view.header, 0, SAB_STATE_PENDING);
68
85
  }
69
- export function waitAndReadGlueReply(view, timeoutMs = 5000) {
86
+ export function waitAndReadGlueReply(view, requestId, timeoutMs = GLUE_HOST_CALL_TIMEOUT_MS) {
70
87
  const res = Atomics.wait(view.header, 0, SAB_STATE_PENDING, timeoutMs);
71
88
  if (res === 'timed-out') {
89
+ Atomics.compareExchange(view.header, 0, SAB_STATE_PENDING, SAB_STATE_IDLE);
72
90
  throw new Error('host call timed out');
73
91
  }
92
+ if (Atomics.load(view.header, 2) !== requestId) {
93
+ Atomics.store(view.header, 0, SAB_STATE_IDLE);
94
+ throw new Error('host call reply id mismatch');
95
+ }
74
96
  const len = Atomics.load(view.header, 1);
97
+ if (len < 0 || len > view.data.length) {
98
+ Atomics.store(view.header, 0, SAB_STATE_IDLE);
99
+ throw new Error('host call reply length is invalid');
100
+ }
75
101
  const out = view.data.slice(0, len);
76
102
  Atomics.store(view.header, 0, SAB_STATE_IDLE);
77
103
  return out;
@@ -39,7 +39,10 @@ export interface PlayerCodeBrokerOptions {
39
39
  * run (09 T7). Compute it from the same bytes the game-api served.
40
40
  */
41
41
  artifactHash?: string;
42
- /** Per-dispatch client fuel budget (from playerComputeArtifact); the glue traps past it. */
42
+ /**
43
+ * Informational server-authored fuel metadata. The browser does not enforce
44
+ * this value; metering must be injected into the platform artifact.
45
+ */
43
46
  fuelPerDispatch?: bigint;
44
47
  onHostCall: (call: PlayerCodeHostCall) => Promise<unknown>;
45
48
  /** Optional sink for HUD/overlay presentation the mod emits (BWF wires this). */
@@ -51,6 +54,10 @@ export interface PlayerCodeBrokerOptions {
51
54
  hashArtifact?: (artifact: ArrayBuffer) => Promise<string>;
52
55
  /** Wall-clock ms allowed per dispatch before the broker recycles the worker. */
53
56
  dispatchWatchdogMs?: number;
57
+ /** Wall-clock ms allowed for worker creation + WASM instantiation. */
58
+ startupWatchdogMs?: number;
59
+ /** Wall-clock ms allowed while an async SDK host call is outstanding. */
60
+ hostCallTimeoutMs?: number;
54
61
  /**
55
62
  * Local tick cadence (ms) for a client mod: the worker self-drives `tick`
56
63
  * at this interval. Omit/0 for invoke-only mods (no periodic tick). A HUD
@@ -81,15 +88,25 @@ export interface PlayerCodeBrokerOptions {
81
88
  export declare class PlayerCodeBroker {
82
89
  private readonly options;
83
90
  private worker;
91
+ private workerListener;
92
+ private artifact;
84
93
  private circuitOpen;
85
94
  private consecutiveTraps;
95
+ private hardTimeouts;
86
96
  private readonly rateBuckets;
87
- private readonly onMessage;
97
+ private globalCallBucket;
98
+ private generation;
99
+ private lifecycleVersion;
100
+ private starting;
101
+ private workerReady;
102
+ private startupTimer;
103
+ private dispatchTimer;
104
+ private activeDispatch;
88
105
  constructor(options: PlayerCodeBrokerOptions);
89
106
  /**
90
107
  * Start the worker on a platform-fetched artifact. Verifies the artifact
91
- * hash (side-load refusal, T7) before handing bytes to the worker, and
92
- * forwards the fuel budget so the glue can trap a runaway dispatch.
108
+ * hash (side-load refusal, T7) before handing bytes to the worker. A retained
109
+ * copy allows the page-side hard watchdog to replace a wedged worker.
93
110
  */
94
111
  start(artifact: ArrayBuffer): Promise<void>;
95
112
  /** Terminate + respawn on a fresh artifact — the client hot-reload path. */
@@ -108,6 +125,18 @@ export declare class PlayerCodeBroker {
108
125
  * the browser; the postMessage is harmless there.
109
126
  */
110
127
  private reply;
128
+ private spawnWorker;
129
+ private stopWorker;
130
+ private armStartupTimer;
131
+ private clearStartupTimer;
132
+ private armDispatchTimer;
133
+ private armHostCallTimer;
134
+ private resumeDispatchWatchdog;
135
+ private finishDispatch;
136
+ private clearDispatchTimer;
137
+ private recycleWorker;
138
+ private openCircuit;
139
+ private enforceGlobalRate;
111
140
  private enforceRate;
112
141
  private recordTrap;
113
142
  private assertGridScope;
@@ -1 +1 @@
1
- {"version":3,"file":"player-code-broker.d.ts","sourceRoot":"","sources":["../../src/player-runtime/player-code-broker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"player-code-broker.d.ts","sourceRoot":"","sources":["../../src/player-runtime/player-code-broker.ts"],"names":[],"mappings":"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"}
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/crowdyjs",
3
- "version": "8.21.1",
3
+ "version": "8.22.0",
4
4
  "description": "Client SDK for Crowded Kingdoms GraphQL API with UDP proxy support",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",