@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.
- package/MIGRATION.md +44 -0
- package/README.md +74 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/live-coding/assets/browser-authoring-index.json +1 -0
- package/dist/live-coding/assets/manifest.json +19 -0
- package/dist/live-coding/assets/tree-sitter-rust.wasm +0 -0
- package/dist/live-coding/assets/web-tree-sitter.wasm +0 -0
- package/dist/live-coding/browser-authoring-index.generated.d.ts +2 -0
- package/dist/live-coding/browser-authoring-index.generated.d.ts.map +1 -0
- package/dist/live-coding/browser-authoring-index.generated.js +5127 -0
- package/dist/live-coding/ide.d.ts +14 -7
- package/dist/live-coding/ide.d.ts.map +1 -1
- package/dist/live-coding/ide.js +296 -181
- package/dist/live-coding/index.d.ts +8 -0
- package/dist/live-coding/index.d.ts.map +1 -0
- package/dist/live-coding/index.js +7 -0
- package/dist/live-coding/lsp-protocol.d.ts +98 -0
- package/dist/live-coding/lsp-protocol.d.ts.map +1 -0
- package/dist/live-coding/lsp-protocol.js +70 -0
- package/dist/live-coding/monaco-services.d.ts +22 -0
- package/dist/live-coding/monaco-services.d.ts.map +1 -0
- package/dist/live-coding/monaco-services.js +69 -0
- package/dist/live-coding/platform-index.d.ts +29 -0
- package/dist/live-coding/platform-index.d.ts.map +1 -0
- package/dist/live-coding/platform-index.js +281 -0
- package/dist/live-coding/rust-analysis.d.ts +24 -0
- package/dist/live-coding/rust-analysis.d.ts.map +1 -0
- package/dist/live-coding/rust-analysis.js +312 -0
- package/dist/live-coding/rust-lsp-server.d.ts +43 -0
- package/dist/live-coding/rust-lsp-server.d.ts.map +1 -0
- package/dist/live-coding/rust-lsp-server.js +455 -0
- package/dist/live-coding/rust-lsp.worker.d.ts +2 -0
- package/dist/live-coding/rust-lsp.worker.d.ts.map +1 -0
- package/dist/live-coding/rust-lsp.worker.js +26 -0
- package/dist/live-coding/vfs.d.ts +41 -0
- package/dist/live-coding/vfs.d.ts.map +1 -0
- package/dist/live-coding/vfs.js +174 -0
- package/dist/live-coding/worker-transport.d.ts +60 -0
- package/dist/live-coding/worker-transport.d.ts.map +1 -0
- package/dist/live-coding/worker-transport.js +204 -0
- package/dist/player-runtime/glue-runtime.d.ts +13 -4
- package/dist/player-runtime/glue-runtime.d.ts.map +1 -1
- package/dist/player-runtime/glue-runtime.js +52 -8
- package/dist/player-runtime/glue-sab.d.ts +8 -5
- package/dist/player-runtime/glue-sab.d.ts.map +1 -1
- package/dist/player-runtime/glue-sab.js +36 -10
- package/dist/player-runtime/player-code-broker.d.ts +33 -4
- package/dist/player-runtime/player-code-broker.d.ts.map +1 -1
- package/dist/player-runtime/player-code-broker.js +332 -51
- package/dist/player-runtime/player-glue-worker.d.ts +3 -2
- package/dist/player-runtime/player-glue-worker.d.ts.map +1 -1
- package/dist/player-runtime/player-glue-worker.js +68 -32
- 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.
|
|
88
|
-
|
|
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
|
|
94
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
this.
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
|
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, {
|
|
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, {
|
|
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,
|
|
218
|
-
this.worker
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
71
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
id: msg.id,
|
|
121
|
-
|
|
122
|
-
|
|
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;
|