@synapse-sdk/client 0.3.3 → 0.3.6
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/calls.d.ts +12 -6
- package/dist/calls.js +35 -18
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/node.d.ts +62 -24
- package/dist/node.js +205 -66
- package/dist/scene.d.ts +166 -61
- package/dist/scene.js +1254 -343
- package/dist/scene_frames.d.ts +2 -2
- package/dist/scene_frames.js +1 -1
- package/dist/session.d.ts +3 -1
- package/dist/session.js +5 -3
- package/dist/store.d.ts +33 -14
- package/dist/store.js +209 -93
- package/dist/synapse.d.ts +73 -49
- package/dist/synapse.js +180 -116
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +24 -10
- package/dist/topics.d.ts +2 -0
- package/dist/topics.js +17 -1
- package/dist/types.d.ts +292 -110
- package/dist/util.d.ts +43 -0
- package/dist/util.js +97 -1
- package/dist/view.d.ts +5 -3
- package/dist/view.js +21 -10
- package/package.json +2 -2
package/dist/scene.js
CHANGED
|
@@ -1,33 +1,50 @@
|
|
|
1
|
-
// The scene (PLAN v0.24 §1, §5, §10), the JS spelling of the Rust client's `scene.rs`:
|
|
2
|
-
// project's scene as this node sees it.
|
|
1
|
+
// The scene (PLAN v0.24 §1, §5, §10; attach PLAN v0.34), the JS spelling of the Rust client's `scene.rs`:
|
|
2
|
+
// the active project's scene as this node sees it.
|
|
3
3
|
//
|
|
4
|
-
// `scene()`
|
|
5
|
-
//
|
|
6
|
-
// frame, then one diff frame per applied batch; a later group is a fresh snapshot that replaces
|
|
7
|
-
// copy) and `live` (each group starts with the whole live set, then one frame per runtime tick). The
|
|
4
|
+
// `scene()` asks the runtime where the live incarnation stands (`synapse/scene/open`: its `epoch`, its
|
|
5
|
+
// `version`) and reads both tracks of that incarnation (`runtime/scene/@<epoch>`): `authored` (a
|
|
6
|
+
// snapshot frame, then one diff frame per applied batch; a later group is a fresh snapshot that replaces
|
|
7
|
+
// the copy) and `live` (each group starts with the whole live set, then one frame per runtime tick). The
|
|
8
8
|
// copy this node keeps (the mirror) is authored ⊕ live: a live row replaces `xform.t/q`, `joints` and
|
|
9
9
|
// `state` of the authored entity it names; a live entity is a row of its own. Nothing is kept across
|
|
10
|
-
// incarnations.
|
|
10
|
+
// incarnations. A diff out of sequence is not applied: the track is subscribed again (its open group
|
|
11
|
+
// starts with a snapshot) and `synced` is false until the copy is back at the version seen (the gap).
|
|
11
12
|
//
|
|
12
13
|
// One task per node, the store's shape: every `scene()` on the node shares it; the task owns the
|
|
13
14
|
// session it is told about (`Node.monitor`), the incarnation it reads, the mirror and the writes in
|
|
14
15
|
// flight, and is driven by one mailbox, in order:
|
|
15
16
|
// - `session` / `sessionEnded` / `nodeEnded`: what was bound on the previous session is over (the
|
|
16
|
-
// mirror empties, the handle is `detached`); the
|
|
17
|
+
// mirror empties, the handle is `detached`); the scene is opened again on the new one;
|
|
18
|
+
// - `opened`: the runtime's answer to the open: its version is wanted and its incarnation read;
|
|
19
|
+
// `project_settling` is asked again, paced; `project_inactive` answers the `scene()` calls waiting
|
|
20
|
+
// (detached) and opens again when the project feed shows a project active;
|
|
17
21
|
// - the incarnation's tracks are read by the task's reader (the only writer of the mirror), which
|
|
18
|
-
// tells the task of each authored frame applied (`applied`)
|
|
19
|
-
// or the tracks ending) empties the mirror and
|
|
22
|
+
// tells the task of each authored frame applied (`applied`) and of the live track's first keyframe
|
|
23
|
+
// (`liveKeyed`); the incarnation's end (its final frame, or the tracks ending) empties the mirror and
|
|
24
|
+
// the scene is opened again (`followEnded`);
|
|
20
25
|
// - `join`: a `scene()` call waiting for the scene to be in (attached, or no project active);
|
|
21
26
|
// - `apply` / `applyDone`: an authoring batch (it names the bound incarnation's epoch) and the
|
|
22
27
|
// runtime's answer; the caller is answered once the mirror shows the version;
|
|
23
28
|
// - `notice`: the runtime dropped some of this node's live rows (`not_owner` with `data.ids`);
|
|
29
|
+
// - `upsert` / `remove` / `claim` / `claimDone` / `reclaim`: this node's live claims (PLAN v0.31: decisions are calls,
|
|
30
|
+
// data is streams): `live.upsert` and `live.remove` are the calls `synapse/scene/live/upsert` and
|
|
31
|
+
// `synapse/scene/live/remove`; a lane write for an id this node has not claimed in the bound incarnation asks the
|
|
32
|
+
// claim once (the override of an entity of the scene); the runtime's answers; a refused live entity due again;
|
|
24
33
|
// - `close`: the last open handle left.
|
|
25
34
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
35
|
+
// The claims are this node's own intent (id → the claim's args), never world state: on every attach to an incarnation
|
|
36
|
+
// (a new session, a project switch, a restarted scene owner) the live entities among them are claimed again with the
|
|
37
|
+
// same ids; an override is claimed again by its next lane write. A refused re-claim is asked again, paced, with a
|
|
38
|
+
// warning (the store's re-open rule and constants: `refusalPaceMs`). Lane rows for an id not claimed in the bound
|
|
39
|
+
// incarnation are parked (latest per lane) while the claim is asked, sent once granted, dropped and counted
|
|
40
|
+
// (`status.get().unclaimedDrops`) when refused. Nothing here reads the mirror to decide what to send: the mirror is a
|
|
41
|
+
// read cache. The Rust client's `scene.rs` is the reference; this file behaves as it does.
|
|
42
|
+
//
|
|
43
|
+
// States: `attached` (the copy is current as of the open: the authored plane at the version the open
|
|
44
|
+
// answered or past it, and the live track's first keyframe applied), `detached` (none: no
|
|
28
45
|
// active project, a session gap, between incarnations), `closed` (`close()` or the node ending). While
|
|
29
|
-
// detached the entities are empty, every author verb fails `project_inactive` at once and
|
|
30
|
-
// drops its batch and counts it. A closed handle's verbs fail `not_connected`.
|
|
46
|
+
// detached the entities are empty, every author verb fails `project_inactive` at once and a live
|
|
47
|
+
// write drops its batch and counts it. A closed handle's verbs fail `not_connected`.
|
|
31
48
|
//
|
|
32
49
|
// Known behaviour (as the Rust client's):
|
|
33
50
|
// - live rows that disappear only through a keyframe (the ticks that removed them were missed) leave
|
|
@@ -42,9 +59,10 @@
|
|
|
42
59
|
import * as Moq from "@moq/net";
|
|
43
60
|
import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs } from "@synapse-sdk/core";
|
|
44
61
|
import { startOn } from "./calls.js";
|
|
45
|
-
import { REFOLLOW_PACE_MS } from "./channel.js";
|
|
62
|
+
import { REFOLLOW_PACE_MS, isTransient, refusalPaceMs } from "./channel.js";
|
|
46
63
|
import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
|
|
47
|
-
import {
|
|
64
|
+
import { ANNOUNCE_WITHIN_MS } from "./store.js";
|
|
65
|
+
import { AsyncQueue, StatusCell, deferred, replaceEqualDeep, rethrowLater, watchState } from "./util.js";
|
|
48
66
|
/** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
|
|
49
67
|
const SCENE_BASE = "runtime/scene";
|
|
50
68
|
const AUTHORED_TRACK = "authored";
|
|
@@ -60,12 +78,22 @@ export const PATCH_PROTECTED = ["id", "name", "parent", "order", "layer", "meta"
|
|
|
60
78
|
/** moq's cap on one frame. */
|
|
61
79
|
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
62
80
|
const EMPTY = Object.freeze({});
|
|
81
|
+
/** What a closed handle's `tree()` and `query()` read: one object each, so a reader that compares by reference stays quiet. */
|
|
82
|
+
const EMPTY_TREE = Object.freeze([]);
|
|
83
|
+
const EMPTY_LIST = Object.freeze([]);
|
|
63
84
|
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
|
|
64
85
|
/** Deep equality of decoded values (structural sharing keeps `a` when equal). */
|
|
65
86
|
const deepEqual = (a, b) => replaceEqualDeep(a, b) === a;
|
|
66
87
|
const parentOf = (e) => (typeof e.parent === "string" ? e.parent : null);
|
|
67
88
|
const orderOf = (e) => (typeof e.order === "string" ? e.order : undefined);
|
|
68
|
-
|
|
89
|
+
/** The id `x` names: a string, or a view this SDK made (`EntityView`); anything else (`{ id }` included) is `invalid_input` naming `arg`. */
|
|
90
|
+
const idOf = (x, arg = "entity") => {
|
|
91
|
+
if (typeof x === "string")
|
|
92
|
+
return x;
|
|
93
|
+
if (x instanceof EntityView)
|
|
94
|
+
return x.id;
|
|
95
|
+
throw new SynapseError("invalid_input", `scene: ${arg} is an id or an entity view (scene.entity, upsert), not ${x === null ? "null" : typeof x}`);
|
|
96
|
+
};
|
|
69
97
|
// ------------------------------------------------------------------------------------- helpers
|
|
70
98
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
71
99
|
const RAND_BITS = 74n;
|
|
@@ -227,17 +255,18 @@ export class Txn {
|
|
|
227
255
|
#spawnedUnder(parent) {
|
|
228
256
|
return this.#ops.flatMap(op => (op.op === "spawn" && op.entity.parent === parent ? [op.entity.id] : []));
|
|
229
257
|
}
|
|
230
|
-
/** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given); its id is minted here (or `opts.id`, a known one) and returned. No `order` is sent: the runtime appends it after its siblings. */
|
|
258
|
+
/** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given, else `calibration`; a live entity is not spawned here, it comes from `scene.live.upsert`); its id is minted here (or `opts.id`, a known one) and returned. No `order` is sent: the runtime appends it after its siblings. */
|
|
231
259
|
spawn(kind, opts) {
|
|
232
260
|
const { parent, id: given, ...components } = opts;
|
|
233
261
|
const id = given ?? ulid();
|
|
234
|
-
const entity = { ...components, id, kind, parent: parent
|
|
262
|
+
const entity = { ...components, id, kind, parent: parent == null ? null : idOf(parent, "parent") };
|
|
235
263
|
entity.layer ??= "authored";
|
|
236
264
|
this.#ops.push({ op: "spawn", entity });
|
|
237
265
|
return id;
|
|
238
266
|
}
|
|
239
|
-
/** Sets components of
|
|
240
|
-
patch(
|
|
267
|
+
/** Sets components of the entity (one level deep: an object merges into an object). */
|
|
268
|
+
patch(entity, set) {
|
|
269
|
+
const id = idOf(entity);
|
|
241
270
|
// A protected key is the runtime's to refuse: such a patch is sent as it is.
|
|
242
271
|
const protectedKey = Object.keys(set).some(k => PATCH_PROTECTED.includes(k));
|
|
243
272
|
const i = protectedKey ? -1 : this.#spawnIndex(id);
|
|
@@ -248,7 +277,8 @@ export class Txn {
|
|
|
248
277
|
}
|
|
249
278
|
this.#ops.push({ op: "patch", id, set });
|
|
250
279
|
}
|
|
251
|
-
rename(
|
|
280
|
+
rename(entity, name) {
|
|
281
|
+
const id = idOf(entity);
|
|
252
282
|
const i = this.#spawnIndex(id);
|
|
253
283
|
if (i >= 0) {
|
|
254
284
|
const op = this.#ops[i];
|
|
@@ -257,17 +287,18 @@ export class Txn {
|
|
|
257
287
|
}
|
|
258
288
|
this.#ops.push({ op: "rename", id, name });
|
|
259
289
|
}
|
|
260
|
-
/** Moves
|
|
261
|
-
reparent(
|
|
262
|
-
const op = { op: "reparent", id, parent };
|
|
290
|
+
/** Moves the entity under `parent` (`null`: the world root), before or after a sibling (default: after the last). */
|
|
291
|
+
reparent(entity, parent, place) {
|
|
292
|
+
const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
|
|
263
293
|
if (place && "before" in place)
|
|
264
|
-
op.before = place.before;
|
|
294
|
+
op.before = idOf(place.before, "before");
|
|
265
295
|
else if (place && "after" in place)
|
|
266
|
-
op.after = place.after;
|
|
296
|
+
op.after = idOf(place.after, "after");
|
|
267
297
|
this.#ops.push(op);
|
|
268
298
|
}
|
|
269
|
-
/** Despawns
|
|
270
|
-
despawn(
|
|
299
|
+
/** Despawns the entity (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
|
|
300
|
+
despawn(entity, opts = {}) {
|
|
301
|
+
const id = idOf(entity);
|
|
271
302
|
const cascade = opts.cascade ?? false;
|
|
272
303
|
if (this.#spawnIndex(id) >= 0 && (cascade || this.#spawnedUnder(id).length === 0)) {
|
|
273
304
|
this.#cancel(id);
|
|
@@ -275,8 +306,8 @@ export class Txn {
|
|
|
275
306
|
}
|
|
276
307
|
this.#ops.push({ op: "despawn", id, cascade });
|
|
277
308
|
}
|
|
278
|
-
/** Locks or unlocks
|
|
279
|
-
setLocked(
|
|
309
|
+
/** Locks or unlocks the entity (a flags-only patch, which a lock lets through). */
|
|
310
|
+
setLocked(entity, locked) { this.patch(entity, { flags: { locked } }); }
|
|
280
311
|
/** Drops this batch's spawn of `id`, every op naming it, and the spawns under it. */
|
|
281
312
|
#cancel(id) {
|
|
282
313
|
const children = this.#spawnedUnder(id);
|
|
@@ -313,7 +344,8 @@ export function foldAuthored(authored, op, t, by) {
|
|
|
313
344
|
const e = authored.get(op.id);
|
|
314
345
|
if (!e)
|
|
315
346
|
return [];
|
|
316
|
-
|
|
347
|
+
// An unchanged component keeps its object, so a view of the authored entity stays quiet on it.
|
|
348
|
+
authored.set(op.id, replaceEqualDeep(e, stamped(mergeSet(e, op.set), op.rev)));
|
|
317
349
|
return [op.id];
|
|
318
350
|
}
|
|
319
351
|
case "reparent": {
|
|
@@ -336,34 +368,6 @@ export function foldAuthored(authored, op, t, by) {
|
|
|
336
368
|
}
|
|
337
369
|
return [];
|
|
338
370
|
}
|
|
339
|
-
/** What changed from `old` to `next`, in the diff ops' shapes (a snapshot replacing the mirror, the mirror emptying): a spawn per new entity, a despawn per gone one, and per changed one a reparent (parent or order), a rename (name) and a patch of the other keys that differ. */
|
|
340
|
-
export function netDifference(old, next) {
|
|
341
|
-
const ops = [];
|
|
342
|
-
for (const [id, e] of next) {
|
|
343
|
-
const o = old.get(id);
|
|
344
|
-
if (!o) {
|
|
345
|
-
ops.push({ op: "spawn", entity: e });
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (o === e || deepEqual(o, e))
|
|
349
|
-
continue;
|
|
350
|
-
const rev = typeof e.meta?.rev === "number" ? e.meta.rev : 0;
|
|
351
|
-
if (parentOf(o) !== parentOf(e) || orderOf(o) !== orderOf(e))
|
|
352
|
-
ops.push({ op: "reparent", id, parent: parentOf(e), order: orderOf(e) ?? "", old_parent: parentOf(o), old_order: orderOf(o) ?? null, rev });
|
|
353
|
-
if (o.name !== e.name && typeof e.name === "string")
|
|
354
|
-
ops.push({ op: "rename", id, name: e.name, old_name: typeof o.name === "string" ? o.name : null, rev });
|
|
355
|
-
const set = {};
|
|
356
|
-
for (const [k, v] of Object.entries(e))
|
|
357
|
-
if (k !== "parent" && k !== "order" && k !== "name" && k !== "meta" && !deepEqual(o[k], v))
|
|
358
|
-
set[k] = v;
|
|
359
|
-
if (Object.keys(set).length)
|
|
360
|
-
ops.push({ op: "patch", id, set: set, rev });
|
|
361
|
-
}
|
|
362
|
-
for (const [id, o] of old)
|
|
363
|
-
if (!next.has(id))
|
|
364
|
-
ops.push({ op: "despawn", id, removed: [id], old_parent: parentOf(o), old_order: orderOf(o) ?? null });
|
|
365
|
-
return ops;
|
|
366
|
-
}
|
|
367
371
|
const list = (v) => (Array.isArray(v) ? v : []);
|
|
368
372
|
/** Writes the lanes of a tick or keyframe into `table`: a lane replaces that lane of the row (made when missing), the others stay. */
|
|
369
373
|
function putLanes(table, f) {
|
|
@@ -393,6 +397,20 @@ export function liveTableOf(k) {
|
|
|
393
397
|
putLanes(t, k);
|
|
394
398
|
return t;
|
|
395
399
|
}
|
|
400
|
+
/** The authored or calibration entity named `name` (live overrides never count). */
|
|
401
|
+
export function authoredNamed(authored, name) {
|
|
402
|
+
for (const e of authored.values())
|
|
403
|
+
if (e.name === name)
|
|
404
|
+
return e;
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
/** The id of the live entity named `name` (a lane-only row carries no name). */
|
|
408
|
+
export function liveNamed(live, name) {
|
|
409
|
+
for (const [id, r] of live)
|
|
410
|
+
if (r.entity?.name === name)
|
|
411
|
+
return id;
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
396
414
|
/** One tick folded into `table` with the runtime's rules (an upsert replaces the whole row, a lane replaces its lane, a remove drops the row); returns the ids it touched. */
|
|
397
415
|
export function foldLive(table, d) {
|
|
398
416
|
const ids = [];
|
|
@@ -461,8 +479,10 @@ export class Mirror {
|
|
|
461
479
|
epoch;
|
|
462
480
|
version = 0;
|
|
463
481
|
hash = "";
|
|
464
|
-
/** An authored diff skipped a version;
|
|
465
|
-
gap
|
|
482
|
+
/** An authored diff skipped a version (not applied; the track is read again from its snapshot): the highest version seen then. Cleared once the copy is back at it (PLAN v0.34 §2.2). */
|
|
483
|
+
gap;
|
|
484
|
+
/** The live track's first keyframe of the incarnation is in the copy. */
|
|
485
|
+
liveKeyed = false;
|
|
466
486
|
pending = 0;
|
|
467
487
|
authored = new Map();
|
|
468
488
|
live = new Map();
|
|
@@ -471,7 +491,7 @@ export class Mirror {
|
|
|
471
491
|
#tree = [];
|
|
472
492
|
#treeStale = false;
|
|
473
493
|
#listeners = new Set();
|
|
474
|
-
#sinks = {
|
|
494
|
+
#sinks = { removals: new Set(), denied: new Set(), states: new Set() };
|
|
475
495
|
subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
|
|
476
496
|
#notify() { for (const l of [...this.#listeners]) {
|
|
477
497
|
try {
|
|
@@ -508,8 +528,6 @@ export class Mirror {
|
|
|
508
528
|
this.state = s;
|
|
509
529
|
this.#send("states", s);
|
|
510
530
|
}
|
|
511
|
-
#emitChanges(ops) { if (ops.length)
|
|
512
|
-
this.#send("changes", ops); }
|
|
513
531
|
/** The merged reads of `ids` recomputed. */
|
|
514
532
|
#refresh(ids) {
|
|
515
533
|
let merged;
|
|
@@ -543,43 +561,51 @@ export class Mirror {
|
|
|
543
561
|
this.merged = replaceEqualDeep(this.merged, merged);
|
|
544
562
|
this.#treeStale = true;
|
|
545
563
|
}
|
|
546
|
-
/** A snapshot of incarnation `epoch` replaces the authored copy
|
|
564
|
+
/** A snapshot of incarnation `epoch` replaces the authored copy. The first one is not yet `attached`: that is the task's, once the copy is at the version the open answered. */
|
|
547
565
|
applySnapshot(epoch, s) {
|
|
548
566
|
if (this.state === "closed")
|
|
549
567
|
return;
|
|
550
568
|
// An entity equal to the one held keeps its object, so a view of the authored entity stays quiet.
|
|
551
569
|
const next = new Map(Object.entries(s.entities).map(([id, e]) => [id, replaceEqualDeep(this.authored.get(id), e)]));
|
|
552
|
-
const ops = netDifference(this.authored, next);
|
|
553
570
|
this.authored = next;
|
|
554
571
|
this.version = s.version;
|
|
555
572
|
this.hash = s.hash;
|
|
556
573
|
this.epoch = epoch;
|
|
557
|
-
this
|
|
574
|
+
this.#heal();
|
|
558
575
|
this.#rebuild();
|
|
559
|
-
this.#emitChanges(ops);
|
|
560
576
|
this.#notify();
|
|
561
577
|
}
|
|
562
|
-
/** The
|
|
578
|
+
/** The copy is current as of the attach (the task checked it): `attached`. */
|
|
563
579
|
attach() { if (this.epoch !== undefined && this.state !== "closed") {
|
|
564
580
|
this.#setState("attached");
|
|
565
581
|
this.#notify();
|
|
566
582
|
} }
|
|
567
|
-
/**
|
|
583
|
+
/** A gap is over once the copy is back at the highest version seen when it opened. */
|
|
584
|
+
#heal() { if (this.gap !== undefined && this.version >= this.gap)
|
|
585
|
+
this.gap = undefined; }
|
|
586
|
+
/**
|
|
587
|
+
* One applied batch: its ops folded into the authored copy, when it is the next version. Otherwise it is not applied
|
|
588
|
+
* (`false`): a gap, `synced` false until the copy is back at the version seen; the reader reads the track again from
|
|
589
|
+
* its snapshot (PLAN v0.34 §2.2).
|
|
590
|
+
*/
|
|
568
591
|
applyDiff(d, warn) {
|
|
569
592
|
if (this.epoch === undefined || this.state === "closed")
|
|
570
|
-
return;
|
|
593
|
+
return true;
|
|
571
594
|
if (d.version !== this.version + 1) {
|
|
572
|
-
warn(`scene: diff out of sequence (have ${this.version}, got ${d.version})
|
|
573
|
-
this.gap =
|
|
595
|
+
warn(`scene: diff out of sequence (have ${this.version}, got ${d.version}): not applied; reading the track again from its snapshot`);
|
|
596
|
+
this.gap = Math.max(this.gap ?? d.version, d.version);
|
|
597
|
+
this.#notify();
|
|
598
|
+
return false;
|
|
574
599
|
}
|
|
575
600
|
const touched = [];
|
|
576
601
|
for (const op of d.ops)
|
|
577
602
|
touched.push(...foldAuthored(this.authored, op, d.t, d.by ?? null));
|
|
578
603
|
this.version = d.version;
|
|
579
604
|
this.hash = d.hash;
|
|
605
|
+
this.#heal();
|
|
580
606
|
this.#refresh(touched);
|
|
581
|
-
this.#emitChanges(d.ops);
|
|
582
607
|
this.#notify();
|
|
608
|
+
return true;
|
|
583
609
|
}
|
|
584
610
|
/** A keyframe replaces the live table. */
|
|
585
611
|
applyKeyframe(k) {
|
|
@@ -590,6 +616,7 @@ export class Mirror {
|
|
|
590
616
|
keepLiveEntities(table, id => old.get(id)?.entity, table.keys());
|
|
591
617
|
const ids = [...this.live.keys(), ...table.keys()];
|
|
592
618
|
this.live = table;
|
|
619
|
+
this.liveKeyed = true;
|
|
593
620
|
this.#refresh(ids);
|
|
594
621
|
this.#notify();
|
|
595
622
|
}
|
|
@@ -610,11 +637,10 @@ export class Mirror {
|
|
|
610
637
|
denied(ids) { if (this.state !== "closed")
|
|
611
638
|
this.#send("denied", ids); }
|
|
612
639
|
setPending(n) { this.pending = n; this.#notify(); }
|
|
613
|
-
/** No incarnation: the copy empties
|
|
640
|
+
/** No incarnation: the copy empties, `detached`. */
|
|
614
641
|
detach() {
|
|
615
642
|
if (this.state === "closed")
|
|
616
643
|
return;
|
|
617
|
-
const ops = netDifference(this.authored, new Map());
|
|
618
644
|
this.authored = new Map();
|
|
619
645
|
this.live = new Map();
|
|
620
646
|
this.merged = EMPTY;
|
|
@@ -622,9 +648,9 @@ export class Mirror {
|
|
|
622
648
|
this.epoch = undefined;
|
|
623
649
|
this.version = 0;
|
|
624
650
|
this.hash = "";
|
|
625
|
-
this.gap =
|
|
651
|
+
this.gap = undefined;
|
|
652
|
+
this.liveKeyed = false;
|
|
626
653
|
this.#setState("detached");
|
|
627
|
-
this.#emitChanges(ops);
|
|
628
654
|
this.#notify();
|
|
629
655
|
}
|
|
630
656
|
/** The end: emptied, `closed` (`reason` recorded), every feed ended. */
|
|
@@ -645,7 +671,7 @@ export class Mirror {
|
|
|
645
671
|
// --------------------------------------------------------------------------------------- views
|
|
646
672
|
/**
|
|
647
673
|
* A value of the scene (`SceneView`): `read` picks it from the mirror (the same object until it
|
|
648
|
-
* changes). It follows the mirror only while someone listens (`
|
|
674
|
+
* changes). It follows the mirror only while someone listens (`onChange`, a pending `changed()`, a `watch()` loop),
|
|
649
675
|
* and while it does it is in `active`, so the handle's close can end it.
|
|
650
676
|
*/
|
|
651
677
|
class MirrorView {
|
|
@@ -654,9 +680,12 @@ class MirrorView {
|
|
|
654
680
|
active;
|
|
655
681
|
ended;
|
|
656
682
|
#listeners = new Set();
|
|
683
|
+
/** Pending `changed()` calls, each with the value it was armed on: it resolves on the first value that differs from it. */
|
|
657
684
|
#waiting = [];
|
|
658
685
|
#unmirror;
|
|
659
686
|
#last;
|
|
687
|
+
/** `end()` ran (it runs once). */
|
|
688
|
+
#over = false;
|
|
660
689
|
constructor(mirror, read, active, ended) {
|
|
661
690
|
this.mirror = mirror;
|
|
662
691
|
this.read = read;
|
|
@@ -664,18 +693,21 @@ class MirrorView {
|
|
|
664
693
|
this.ended = ended;
|
|
665
694
|
}
|
|
666
695
|
get() { return this.read(); }
|
|
667
|
-
|
|
696
|
+
/** `cb` with the value on each change, and once more when the view ends; returns the remover. */
|
|
697
|
+
onChange(cb) {
|
|
668
698
|
if (this.ended())
|
|
669
699
|
return () => { };
|
|
670
|
-
this.#listeners.add(
|
|
700
|
+
this.#listeners.add(cb);
|
|
671
701
|
this.#follow();
|
|
672
|
-
return () => { this.#listeners.delete(
|
|
702
|
+
return () => { this.#listeners.delete(cb); this.#unfollow(); };
|
|
673
703
|
}
|
|
704
|
+
/** The value after each change; ends with the handle. */
|
|
705
|
+
watch() { return watchState(cb => this.onChange(cb), () => this.get(), this.ended); }
|
|
674
706
|
changed() {
|
|
675
707
|
if (this.ended())
|
|
676
708
|
return Promise.resolve(this.read());
|
|
677
709
|
const d = deferred();
|
|
678
|
-
this.#waiting.push(d);
|
|
710
|
+
this.#waiting.push({ d, armedOn: this.read() });
|
|
679
711
|
this.#follow();
|
|
680
712
|
return d.promise;
|
|
681
713
|
}
|
|
@@ -694,6 +726,11 @@ class MirrorView {
|
|
|
694
726
|
this.active.delete(this);
|
|
695
727
|
}
|
|
696
728
|
#update() {
|
|
729
|
+
// The node ended (the mirror closed): the view ends with it.
|
|
730
|
+
if (this.ended()) {
|
|
731
|
+
this.end();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
697
734
|
let v;
|
|
698
735
|
try {
|
|
699
736
|
v = this.read();
|
|
@@ -705,20 +742,28 @@ class MirrorView {
|
|
|
705
742
|
if (v === this.#last)
|
|
706
743
|
return;
|
|
707
744
|
this.#last = v;
|
|
745
|
+
// The waits this change answers are taken before the listeners run: a `changed()` a listener arms waits for the next change.
|
|
746
|
+
// A wait armed on this value already (a scene-level callback ran before this view's update) is not answered by it: it keeps waiting.
|
|
747
|
+
const waiting = this.#waiting.splice(0);
|
|
748
|
+
this.#waiting.push(...waiting.filter(w => w.armedOn === v));
|
|
708
749
|
for (const l of [...this.#listeners]) {
|
|
709
750
|
try {
|
|
710
|
-
l();
|
|
751
|
+
l(v);
|
|
711
752
|
}
|
|
712
753
|
catch (e) {
|
|
713
754
|
rethrowLater(e);
|
|
714
755
|
}
|
|
715
756
|
}
|
|
716
|
-
for (const
|
|
717
|
-
|
|
757
|
+
for (const w of waiting)
|
|
758
|
+
if (w.armedOn !== v)
|
|
759
|
+
w.d.resolve(v);
|
|
718
760
|
this.#unfollow();
|
|
719
761
|
}
|
|
720
|
-
/** The handle closed: a pending `changed()` resolves with the value now;
|
|
762
|
+
/** The handle closed: a pending `changed()` resolves with the value now; each callback is told once, then dropped (a `watch()` loop ends there). Once: a callback that closes the handle again is not told twice. */
|
|
721
763
|
end() {
|
|
764
|
+
if (this.#over)
|
|
765
|
+
return;
|
|
766
|
+
this.#over = true;
|
|
722
767
|
let v;
|
|
723
768
|
try {
|
|
724
769
|
v = this.read();
|
|
@@ -726,12 +771,91 @@ class MirrorView {
|
|
|
726
771
|
catch {
|
|
727
772
|
v = this.#last;
|
|
728
773
|
}
|
|
729
|
-
for (const
|
|
730
|
-
d.resolve(v);
|
|
774
|
+
for (const w of this.#waiting.splice(0))
|
|
775
|
+
w.d.resolve(v);
|
|
776
|
+
for (const l of [...this.#listeners]) {
|
|
777
|
+
try {
|
|
778
|
+
l(v);
|
|
779
|
+
}
|
|
780
|
+
catch (e) {
|
|
781
|
+
rethrowLater(e);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
731
784
|
this.#listeners.clear();
|
|
732
785
|
this.#unfollow();
|
|
733
786
|
}
|
|
734
787
|
}
|
|
788
|
+
/** `e`, or its `component` (`undefined` when `e` is). */
|
|
789
|
+
const pick = (e, component) => component === undefined ? e : e === undefined ? undefined : e[component];
|
|
790
|
+
/**
|
|
791
|
+
* One entity as a view (`scene.entity(id | { name })`): the `SceneView` of the merged entity with the store handle's
|
|
792
|
+
* members, and the entity's two planes (`authored`, `live`), frozen objects made once with the view. `read` picks the
|
|
793
|
+
* merged entity for the id the view names now; `ops.write` patches the authored entity the view names at the call,
|
|
794
|
+
* `ops.live` writes its live lanes, `ops.remove` drops its live row.
|
|
795
|
+
*/
|
|
796
|
+
class EntityView extends MirrorView {
|
|
797
|
+
ops;
|
|
798
|
+
authored;
|
|
799
|
+
live;
|
|
800
|
+
constructor(mirror, read, active, ended, ops) {
|
|
801
|
+
super(mirror, read, active, ended);
|
|
802
|
+
this.ops = ops;
|
|
803
|
+
this.authored = Object.freeze({
|
|
804
|
+
get: ((component) => pick(ops.readAuthored(), component)),
|
|
805
|
+
set: ((a, b) => ops.write(typeof a === "string" ? { [a]: b } : a)),
|
|
806
|
+
});
|
|
807
|
+
this.live = Object.freeze({
|
|
808
|
+
get: ((component) => pick(ops.readLive(), component)),
|
|
809
|
+
set: (components, opts = {}) => ops.live(components, opts),
|
|
810
|
+
remove: () => ops.remove(),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
get id() { return this.ops.id(); }
|
|
814
|
+
get(component) { return pick(this.read(), component); }
|
|
815
|
+
/**
|
|
816
|
+
* The entity (or `component` of it) after each change; changes in between fold into the latest. A component is
|
|
817
|
+
* compared by reference: the mirror keeps an unchanged component's object across a change elsewhere (structural
|
|
818
|
+
* sharing), so only a change of that component wakes it.
|
|
819
|
+
*/
|
|
820
|
+
watch(component) {
|
|
821
|
+
return watchState(cb => this.onChange(cb), () => this.get(component), this.ended);
|
|
822
|
+
}
|
|
823
|
+
/** The entity (or `component` of it) after its next change, as `watch(component)` yields it; the value now once the handle ends. Armed at the call. */
|
|
824
|
+
changed(component) {
|
|
825
|
+
if (component === undefined)
|
|
826
|
+
return super.changed();
|
|
827
|
+
if (this.ended())
|
|
828
|
+
return Promise.resolve(this.get(component));
|
|
829
|
+
// the first step of `watch(component)`, started now (its baseline is read at the first `next()`), then left
|
|
830
|
+
const it = this.watch(component)[Symbol.asyncIterator]();
|
|
831
|
+
return it.next().then(r => { void it.return?.(undefined); return r.done ? this.get(component) : r.value; });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/** Releases a claim this node does not keep (granted after its handle closed, or after a `live.remove`): the call `synapse/scene/live/remove`, best effort. */
|
|
835
|
+
function release(node, session, id) {
|
|
836
|
+
if (!session)
|
|
837
|
+
return;
|
|
838
|
+
try {
|
|
839
|
+
void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
|
|
840
|
+
}
|
|
841
|
+
catch { /* the session's end releases it */ }
|
|
842
|
+
}
|
|
843
|
+
/** The id a claim's reply names. */
|
|
844
|
+
function replyId(v) {
|
|
845
|
+
const id = v?.id;
|
|
846
|
+
if (typeof id !== "string")
|
|
847
|
+
throw new SynapseError("internal", "scene live claim reply not understood");
|
|
848
|
+
return id;
|
|
849
|
+
}
|
|
850
|
+
const sessionGone = (e) => e.code === "connection_lost" || e.code === "not_connected";
|
|
851
|
+
/** The answer of `synapse/scene/open`: where the live incarnation stands. */
|
|
852
|
+
function parseOpen(reply) {
|
|
853
|
+
const r = reply;
|
|
854
|
+
const version = typeof r?.version === "bigint" ? Number(r.version) : r?.version;
|
|
855
|
+
if (typeof r?.epoch !== "string" || typeof version !== "number")
|
|
856
|
+
throw new SynapseError("internal", "scene open reply not understood");
|
|
857
|
+
return { epoch: r.epoch, version };
|
|
858
|
+
}
|
|
735
859
|
function openLiveWriter(s) {
|
|
736
860
|
const bc = s.createBroadcast(`${s.prefix}/scene/live`);
|
|
737
861
|
// Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
|
|
@@ -748,6 +872,82 @@ function closeLiveWriter(w) {
|
|
|
748
872
|
}
|
|
749
873
|
catch { /* closed */ }
|
|
750
874
|
}
|
|
875
|
+
/** A live batch's wire bytes. */
|
|
876
|
+
function encodeLive(body) {
|
|
877
|
+
let bytes;
|
|
878
|
+
try {
|
|
879
|
+
bytes = encodeBody(body);
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
|
|
883
|
+
}
|
|
884
|
+
if (bytes.length > MAX_FRAME_BYTES)
|
|
885
|
+
throw frameTooLarge("scene live batch", bytes.length);
|
|
886
|
+
return bytes;
|
|
887
|
+
}
|
|
888
|
+
/** One frame on the live writer. */
|
|
889
|
+
function writeLive(w, bytes) {
|
|
890
|
+
const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
|
|
891
|
+
w.seq = (w.seq + 1) >>> 0;
|
|
892
|
+
try {
|
|
893
|
+
w.stream.write(header, bytes);
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/** Session `s`'s live writer: the current one, or a new one when that is another session's. */
|
|
900
|
+
function liveWriterOn(scene, s) {
|
|
901
|
+
let w = scene.writer;
|
|
902
|
+
if (w && w.session === s)
|
|
903
|
+
return w;
|
|
904
|
+
closeLiveWriter(w);
|
|
905
|
+
try {
|
|
906
|
+
w = scene.writer = openLiveWriter(s);
|
|
907
|
+
}
|
|
908
|
+
catch (e) {
|
|
909
|
+
scene.writer = undefined;
|
|
910
|
+
throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
|
|
911
|
+
}
|
|
912
|
+
return w;
|
|
913
|
+
}
|
|
914
|
+
const UNCLAIMED = { kind: "unclaimed" }, ASKING = { kind: "asking" }, CLAIMED = { kind: "claimed" };
|
|
915
|
+
const parkedRows = (p) => (p.pose ? 1 : 0) + (p.joints ? 1 : 0) + (p.state ? 1 : 0);
|
|
916
|
+
/** One of this node's claims: its own intent, never world state. */
|
|
917
|
+
class Claim {
|
|
918
|
+
args;
|
|
919
|
+
state;
|
|
920
|
+
/** Refusals of it in a row (the pace of the next ask). */
|
|
921
|
+
refusals = 0;
|
|
922
|
+
parked = {};
|
|
923
|
+
/** `args`: the `upsert` call's args besides `id` (a live entity's `kind`, `name`, `parent`, `lifetime_ms` and components); empty for the override of an entity of the scene. */
|
|
924
|
+
constructor(args, state) {
|
|
925
|
+
this.args = args;
|
|
926
|
+
this.state = state;
|
|
927
|
+
}
|
|
928
|
+
/** A live entity (claimed again on every attach), not an override (claimed by its lanes). */
|
|
929
|
+
get isEntity() { return Object.keys(this.args).length > 0; }
|
|
930
|
+
}
|
|
931
|
+
/** When the task's timer asks `c` again: a paced live entity (refused or transient), or a claim paced after a transient answer with rows parked; a refused override is asked by its next lane write instead. */
|
|
932
|
+
function timerDue(c) {
|
|
933
|
+
const st = c.state;
|
|
934
|
+
return st.kind === "paced" && (c.isEntity || (!st.refused && parkedRows(c.parked) > 0)) ? st.at : undefined;
|
|
935
|
+
}
|
|
936
|
+
/** A lane batch on the wire: data only (stamps are the runtime's). */
|
|
937
|
+
function laneBody(poses, joints, state, lifetimeMs) {
|
|
938
|
+
const body = {};
|
|
939
|
+
if (poses.length)
|
|
940
|
+
body.poses = poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
|
|
941
|
+
if (joints.length)
|
|
942
|
+
body.joints = joints.map(r => ({ id: r.id, j: r.j }));
|
|
943
|
+
if (state.length)
|
|
944
|
+
body.state = state.map(r => ({ id: r.id, s: r.s }));
|
|
945
|
+
if (!Object.keys(body).length)
|
|
946
|
+
return undefined;
|
|
947
|
+
if (lifetimeMs !== undefined)
|
|
948
|
+
body.lifetime_ms = lifetimeMs;
|
|
949
|
+
return body;
|
|
950
|
+
}
|
|
751
951
|
/** The node's scene: what every handle shares with the task. */
|
|
752
952
|
export class Scene {
|
|
753
953
|
node;
|
|
@@ -758,14 +958,52 @@ export class Scene {
|
|
|
758
958
|
/** Handles neither closed nor dropped; the one that brings it to 0 tells the task `close`. */
|
|
759
959
|
handles = 0;
|
|
760
960
|
nextHandle = 0;
|
|
761
|
-
/** The live writer of the current session, made on the first
|
|
961
|
+
/** The live writer of the current session, made on the first live write. */
|
|
762
962
|
writer;
|
|
963
|
+
/** This node's claims by id (what `live.upsert` made and what lane writes asked for); `live.remove` drops one. Nothing else is kept. */
|
|
964
|
+
claims = new Map();
|
|
763
965
|
detachedDrops = 0;
|
|
966
|
+
/** Lane rows dropped because their id's claim was refused (and not due again), or parked rows a refusal dropped. */
|
|
967
|
+
unclaimedDrops = 0;
|
|
968
|
+
/** Told when a counter of the status changes (the listeners of the status state values). */
|
|
969
|
+
counters = new Set();
|
|
764
970
|
constructor(node, warn) {
|
|
765
971
|
this.node = node;
|
|
766
972
|
this.warn = warn;
|
|
767
973
|
}
|
|
768
974
|
post(e) { return this.mailbox.push(e); }
|
|
975
|
+
countersChanged() { for (const l of [...this.counters]) {
|
|
976
|
+
try {
|
|
977
|
+
l();
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
rethrowLater(e);
|
|
981
|
+
}
|
|
982
|
+
} }
|
|
983
|
+
/**
|
|
984
|
+
* What happens to a lane row of `id` now. An id with no claim becomes the override's claim; an override that is
|
|
985
|
+
* unclaimed, or refused and due again, is marked asked and returned in `ask` (the task asks it: one ask per id at a
|
|
986
|
+
* time). A live entity's claim is asked by the task (on attach, paced after a refusal), never by a lane.
|
|
987
|
+
*/
|
|
988
|
+
gate(id, now, ask) {
|
|
989
|
+
let c = this.claims.get(id);
|
|
990
|
+
if (!c)
|
|
991
|
+
this.claims.set(id, (c = new Claim({}, UNCLAIMED)));
|
|
992
|
+
const st = c.state;
|
|
993
|
+
if (st.kind === "claimed")
|
|
994
|
+
return "send";
|
|
995
|
+
if (st.kind === "asking")
|
|
996
|
+
return "park";
|
|
997
|
+
if (st.kind === "unclaimed" && c.isEntity)
|
|
998
|
+
return "park";
|
|
999
|
+
if (st.kind === "paced" && !st.refused)
|
|
1000
|
+
return "park";
|
|
1001
|
+
if (st.kind === "paced" && (c.isEntity || st.at > now))
|
|
1002
|
+
return "drop";
|
|
1003
|
+
c.state = ASKING;
|
|
1004
|
+
ask.push(id);
|
|
1005
|
+
return "park";
|
|
1006
|
+
}
|
|
769
1007
|
request(make) {
|
|
770
1008
|
const reply = deferred();
|
|
771
1009
|
if (!this.post(make(reply)))
|
|
@@ -778,6 +1016,16 @@ const detachedError = () => new SynapseError("project_inactive", "the scene is d
|
|
|
778
1016
|
function closedError(reason) {
|
|
779
1017
|
return new SynapseError("not_connected", reason === ENDED_CONNECTION_LOST ? "the node ended" : "the scene handle is closed");
|
|
780
1018
|
}
|
|
1019
|
+
/** A view by name whose name no entity holds: nothing to send to (`not_found`, as the runtime answers an unknown id). */
|
|
1020
|
+
function notNamed(ref) {
|
|
1021
|
+
const name = typeof ref === "string" ? undefined : ref.name;
|
|
1022
|
+
return new SynapseError("not_found", { message: `scene: no entity is named ${JSON.stringify(name)}`, data: { name } });
|
|
1023
|
+
}
|
|
1024
|
+
/** An `upsert` is keyed by its name: an `id` component (the type forbids it; JavaScript does not) is `invalid_input`, nothing sent. */
|
|
1025
|
+
function refuseId(opts, verb) {
|
|
1026
|
+
if ("id" in opts)
|
|
1027
|
+
throw new SynapseError("invalid_input", { message: `${verb} is keyed by its name: an id component is not taken`, data: { component: "id" } });
|
|
1028
|
+
}
|
|
781
1029
|
const asError = (e) => (e instanceof SynapseError ? e : new SynapseError("internal", String(e)));
|
|
782
1030
|
export function startScene(scene, registry) { void run(scene, registry); }
|
|
783
1031
|
async function run(scene, registry) {
|
|
@@ -786,13 +1034,33 @@ async function run(scene, registry) {
|
|
|
786
1034
|
let phase = { kind: "idle" };
|
|
787
1035
|
const ph = () => phase;
|
|
788
1036
|
let attempts = 0;
|
|
789
|
-
/**
|
|
790
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* The incarnation the open answered and its version then: the copy is current (the handle `attached`) once it holds
|
|
1039
|
+
* that version and the live track's first keyframe (PLAN v0.34 §2.1).
|
|
1040
|
+
*/
|
|
1041
|
+
let wanted;
|
|
791
1042
|
/** The session's project state as last read. */
|
|
792
1043
|
let project;
|
|
793
1044
|
let unwatchProject;
|
|
794
1045
|
const joining = [];
|
|
795
1046
|
let waiters = [];
|
|
1047
|
+
/** The attach the claims are made for (session and incarnation), if any. */
|
|
1048
|
+
let bound;
|
|
1049
|
+
/** Counts attaches: an answer to a claim asked in an earlier one is not this one's. */
|
|
1050
|
+
let bindNo = 0;
|
|
1051
|
+
/** The ids whose claim is being asked again (one ask per id at a time). */
|
|
1052
|
+
const asking = new Set();
|
|
1053
|
+
/** `live.upsert` calls waiting for an attach (attached a moment before the task was told, or their session went before the answer). */
|
|
1054
|
+
let parkedUpserts = [];
|
|
1055
|
+
/**
|
|
1056
|
+
* The `live.upsert` calls on the wire, by token: the id each named (a claim of that name this node held), and whether its
|
|
1057
|
+
* answer may still be kept (`false` once a `live.remove` of that id or the handle's close came after it: a grant is then
|
|
1058
|
+
* released, never kept).
|
|
1059
|
+
*/
|
|
1060
|
+
const firsts = new Map();
|
|
1061
|
+
let nextFirst = 0;
|
|
1062
|
+
let reclaimTimer;
|
|
1063
|
+
let reclaimAt = Infinity;
|
|
796
1064
|
const inactive = () => project !== undefined && project.active === null;
|
|
797
1065
|
const answerJoins = () => { for (const j of joining.splice(0))
|
|
798
1066
|
j.resolve(); };
|
|
@@ -803,10 +1071,15 @@ async function run(scene, registry) {
|
|
|
803
1071
|
phase.stop.abort();
|
|
804
1072
|
else if (phase.kind === "paced")
|
|
805
1073
|
clearTimeout(phase.timer);
|
|
1074
|
+
else if (phase.kind === "opening")
|
|
1075
|
+
phase.announced.close();
|
|
806
1076
|
phase = { kind: "idle" };
|
|
807
1077
|
};
|
|
808
|
-
/**
|
|
809
|
-
|
|
1078
|
+
/**
|
|
1079
|
+
* From `idle`: the announcement cursor under `runtime/scene`, then `synapse/scene/open` on the task's session (the
|
|
1080
|
+
* store's order); its answer comes back as `opened`.
|
|
1081
|
+
*/
|
|
1082
|
+
const open = () => {
|
|
810
1083
|
if (phase.kind !== "idle" || mirror.state === "closed" || !session)
|
|
811
1084
|
return;
|
|
812
1085
|
const s = session;
|
|
@@ -818,26 +1091,87 @@ async function run(scene, registry) {
|
|
|
818
1091
|
scene.warn(`scene: announcements not followed: ${e instanceof Error ? e.message : String(e)}`);
|
|
819
1092
|
return;
|
|
820
1093
|
}
|
|
1094
|
+
let run;
|
|
1095
|
+
try {
|
|
1096
|
+
run = startOn(node, s, "synapse/scene/open", {}, {});
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
announced.close();
|
|
1100
|
+
return; /* the session is going: its end is on its way */
|
|
1101
|
+
}
|
|
821
1102
|
attempts += 1;
|
|
822
1103
|
const attempt = attempts;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1104
|
+
phase = { kind: "opening", attempt, session: s, announced };
|
|
1105
|
+
void run.result.then(v => scene.post({ kind: "opened", attempt, result: { ok: v } }), (e) => scene.post({ kind: "opened", attempt, result: { err: asError(e) } }));
|
|
1106
|
+
};
|
|
1107
|
+
/**
|
|
1108
|
+
* The open of attempt `attempt` answered (one no longer in flight is dropped): the incarnation it names is read, its
|
|
1109
|
+
* version wanted. `project_settling` (or another transient refusal) is asked again, paced; `project_inactive` answers
|
|
1110
|
+
* the waiting `scene()` calls (detached) and waits for the active project to change (at once, paced, when the session
|
|
1111
|
+
* already shows one); so does a scene the runtime cannot serve (warned); the session going is left to its end.
|
|
1112
|
+
*/
|
|
1113
|
+
const opened = (attempt, result) => {
|
|
1114
|
+
const p = ph();
|
|
1115
|
+
if (p.kind !== "opening" || p.attempt !== attempt)
|
|
1116
|
+
return;
|
|
1117
|
+
const { session: s, announced } = p;
|
|
1118
|
+
phase = { kind: "idle" };
|
|
1119
|
+
let reply;
|
|
1120
|
+
if ("ok" in result) {
|
|
1121
|
+
try {
|
|
1122
|
+
reply = { ok: parseOpen(result.ok) };
|
|
1123
|
+
}
|
|
1124
|
+
catch (e) {
|
|
1125
|
+
reply = { err: asError(e) };
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
else
|
|
1129
|
+
reply = result;
|
|
1130
|
+
if ("ok" in reply) {
|
|
1131
|
+
wanted = reply.ok;
|
|
1132
|
+
const stop = new AbortController();
|
|
1133
|
+
phase = { kind: "following", attempt, stop };
|
|
1134
|
+
void followIncarnation(scene, s, announced, reply.ok.epoch, stop.signal).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
announced.close();
|
|
1138
|
+
const e = reply.err;
|
|
1139
|
+
if (sessionGone(e)) {
|
|
1140
|
+
session = undefined;
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (isTransient(e)) {
|
|
1144
|
+
again(REFOLLOW_PACE_MS);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (e.code === "project_inactive")
|
|
1148
|
+
answerJoins();
|
|
1149
|
+
else
|
|
1150
|
+
scene.warn(`scene: open refused by the runtime (${e.code}${e.message ? `: ${e.message}` : ""}); opened again when the active project changes`);
|
|
1151
|
+
const shown = !!project?.active;
|
|
1152
|
+
if (e.code === "project_inactive" && shown)
|
|
1153
|
+
again(REFOLLOW_PACE_MS);
|
|
1154
|
+
else
|
|
1155
|
+
phase = { kind: "waitingProject" };
|
|
826
1156
|
};
|
|
827
|
-
/** The incarnation is let go: the mirror empties, writes waiting to show fail,
|
|
1157
|
+
/** The incarnation is let go: the mirror empties, writes waiting to show fail, opened again after `paceMs`. */
|
|
828
1158
|
const again = (paceMs) => {
|
|
829
1159
|
failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
|
|
830
1160
|
stopPhase();
|
|
831
1161
|
mirror.detach();
|
|
1162
|
+
unbind();
|
|
1163
|
+
wanted = undefined;
|
|
832
1164
|
const a = ++attempts;
|
|
833
1165
|
phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
|
|
834
1166
|
};
|
|
835
|
-
/** The node's session is now `s` (or none): what was bound on the previous one is over; the
|
|
1167
|
+
/** The node's session is now `s` (or none): what was bound on the previous one is over; the scene is opened on `s`. */
|
|
836
1168
|
const sessionChanged = (s) => {
|
|
837
1169
|
session = s;
|
|
838
1170
|
stopPhase();
|
|
1171
|
+
wanted = undefined;
|
|
839
1172
|
failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
|
|
840
1173
|
mirror.detach();
|
|
1174
|
+
unbind();
|
|
841
1175
|
closeLiveWriter(scene.writer);
|
|
842
1176
|
scene.writer = undefined;
|
|
843
1177
|
unwatchProject?.();
|
|
@@ -847,15 +1181,333 @@ async function run(scene, registry) {
|
|
|
847
1181
|
unwatchProject = s.project.subscribe(p => { scene.post({ kind: "project", snapshot: p }); });
|
|
848
1182
|
if (inactive())
|
|
849
1183
|
answerJoins();
|
|
850
|
-
|
|
1184
|
+
open();
|
|
851
1185
|
};
|
|
1186
|
+
/** The session's project feed changed: with none active the waiting `scene()` calls are answered; a task waiting for a project opens again. */
|
|
1187
|
+
const projectChanged = (snapshot) => {
|
|
1188
|
+
project = snapshot;
|
|
1189
|
+
if (inactive())
|
|
1190
|
+
answerJoins();
|
|
1191
|
+
if (phase.kind === "waitingProject") {
|
|
1192
|
+
phase = { kind: "idle" };
|
|
1193
|
+
open();
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
/** The reader applied an authored frame of `epoch` reaching `version`: the writes it shows are answered; the handle attaches when the copy is current. */
|
|
852
1197
|
const applied = (epoch, version) => {
|
|
853
1198
|
const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
|
|
854
1199
|
waiters = waiters.filter(w => !reached.includes(w));
|
|
855
1200
|
for (const w of reached)
|
|
856
1201
|
w.reply.resolve();
|
|
857
|
-
|
|
858
|
-
|
|
1202
|
+
attach();
|
|
1203
|
+
};
|
|
1204
|
+
/**
|
|
1205
|
+
* The copy is current as of the open (PLAN v0.34 §2.1: the authored plane at the version the open answered, or past
|
|
1206
|
+
* it, and the live track's first keyframe applied, both of the incarnation the open named): the claims are made for it
|
|
1207
|
+
* first (`bind`), then the handle is `attached` (one path: nothing a handle does after it sees `attached` meets an
|
|
1208
|
+
* unbound attach). Attached, the `scene()` calls waiting are answered.
|
|
1209
|
+
*/
|
|
1210
|
+
const attach = () => {
|
|
1211
|
+
const current = mirror.state === "attached"
|
|
1212
|
+
|| (wanted !== undefined && mirror.state === "detached" && mirror.epoch === wanted.epoch && mirror.version >= wanted.version && mirror.liveKeyed);
|
|
1213
|
+
if (!current)
|
|
1214
|
+
return;
|
|
1215
|
+
if (mirror.state !== "attached") {
|
|
1216
|
+
if (!session || !wanted)
|
|
1217
|
+
return;
|
|
1218
|
+
if (bound?.session !== session || bound.epoch !== wanted.epoch)
|
|
1219
|
+
bind(session, wanted.epoch);
|
|
1220
|
+
mirror.attach();
|
|
1221
|
+
}
|
|
1222
|
+
answerJoins();
|
|
1223
|
+
};
|
|
1224
|
+
// ---- claims (PLAN v0.31)
|
|
1225
|
+
/** The claims are made for an attach no more (a session gap, an incarnation let go): each is unclaimed; answers of asks made before are not this attach's. */
|
|
1226
|
+
const unbind = () => {
|
|
1227
|
+
bound = undefined;
|
|
1228
|
+
bindNo += 1;
|
|
1229
|
+
asking.clear();
|
|
1230
|
+
// Rows parked for the attach that ended are not this one's data.
|
|
1231
|
+
for (const c of scene.claims.values()) {
|
|
1232
|
+
c.state = UNCLAIMED;
|
|
1233
|
+
c.parked = {};
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
/** Attached to incarnation `epoch` on session `s`: every live entity of this node's is claimed again, same id (an override, by its next lane write); the `live.upsert` calls waiting are asked. */
|
|
1237
|
+
const bind = (s, epoch) => {
|
|
1238
|
+
unbind();
|
|
1239
|
+
bound = { session: s, epoch };
|
|
1240
|
+
for (const [id, c] of [...scene.claims])
|
|
1241
|
+
if (c.isEntity)
|
|
1242
|
+
askAgain(id);
|
|
1243
|
+
const waiting = parkedUpserts;
|
|
1244
|
+
parkedUpserts = [];
|
|
1245
|
+
for (const u of waiting)
|
|
1246
|
+
askFirst(u.args, u.reply);
|
|
1247
|
+
};
|
|
1248
|
+
/** Starts the claim call with `args` in this attach; its answer comes back as `claimDone`. Returns the error when it could not be sent. */
|
|
1249
|
+
const callClaim = (ask, args) => {
|
|
1250
|
+
if (!session || !bound)
|
|
1251
|
+
return detachedError();
|
|
1252
|
+
let call;
|
|
1253
|
+
try {
|
|
1254
|
+
call = startOn(node, session, "synapse/scene/live/upsert", args, {});
|
|
1255
|
+
}
|
|
1256
|
+
catch (e) {
|
|
1257
|
+
return asError(e);
|
|
1258
|
+
}
|
|
1259
|
+
const b = bindNo;
|
|
1260
|
+
const s = session;
|
|
1261
|
+
const done = (result) => {
|
|
1262
|
+
if (scene.post({ kind: "claimDone", ask, bind: b, result }))
|
|
1263
|
+
return;
|
|
1264
|
+
// The task is gone (the handle closed): a grant is released, never kept; a `live.upsert` caller is answered from here.
|
|
1265
|
+
if ("ok" in result) {
|
|
1266
|
+
try {
|
|
1267
|
+
release(node, s, replyId(result.ok));
|
|
1268
|
+
}
|
|
1269
|
+
catch { /* not understood: nothing to release */ }
|
|
1270
|
+
}
|
|
1271
|
+
if (ask.kind === "first")
|
|
1272
|
+
ask.reply.reject(closedError(ENDED_CLIENT));
|
|
1273
|
+
};
|
|
1274
|
+
void call.result.then(v => done({ ok: v }), (e) => done({ err: asError(e) }));
|
|
1275
|
+
return undefined;
|
|
1276
|
+
};
|
|
1277
|
+
/** Asks the claim of `id` again with its args (one ask per id at a time). */
|
|
1278
|
+
const askAgain = (id) => {
|
|
1279
|
+
if (!bound || asking.has(id))
|
|
1280
|
+
return;
|
|
1281
|
+
const c = scene.claims.get(id);
|
|
1282
|
+
if (!c)
|
|
1283
|
+
return;
|
|
1284
|
+
c.state = ASKING;
|
|
1285
|
+
asking.add(id);
|
|
1286
|
+
if (callClaim({ kind: "again", id }, { ...c.args, id })) {
|
|
1287
|
+
// Not sent: asked again at the next attach.
|
|
1288
|
+
asking.delete(id);
|
|
1289
|
+
const now = scene.claims.get(id);
|
|
1290
|
+
if (now)
|
|
1291
|
+
now.state = UNCLAIMED;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
/** A `live.upsert`: asked with the id of this node's claim of that name, if it has one (its own intent), else without an id (the runtime mints it). */
|
|
1295
|
+
const askFirst = (args, reply) => {
|
|
1296
|
+
const callArgs = { ...args };
|
|
1297
|
+
let named;
|
|
1298
|
+
if (args.name !== undefined)
|
|
1299
|
+
for (const [id, c] of scene.claims)
|
|
1300
|
+
if (c.args.name === args.name) {
|
|
1301
|
+
callArgs.id = named = id;
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
const token = nextFirst++;
|
|
1305
|
+
firsts.set(token, { named, keep: true });
|
|
1306
|
+
const e = callClaim({ kind: "first", args, reply, token }, callArgs);
|
|
1307
|
+
if (e) {
|
|
1308
|
+
firsts.delete(token);
|
|
1309
|
+
reply.reject(e);
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
const upsert = (args, reply) => {
|
|
1313
|
+
if (mirror.state === "closed")
|
|
1314
|
+
reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
|
|
1315
|
+
else if (mirror.state === "detached")
|
|
1316
|
+
reply.reject(detachedError());
|
|
1317
|
+
// Attached: the claims are bound (the task attaches the handle after the bind: one path).
|
|
1318
|
+
else
|
|
1319
|
+
askFirst(args, reply);
|
|
1320
|
+
};
|
|
1321
|
+
/** A lane write asked the claim of `id`: asked now when bound; otherwise unclaimed (the next lane write after the attach asks it). */
|
|
1322
|
+
const claimAsked = (id) => {
|
|
1323
|
+
if (bound) {
|
|
1324
|
+
askAgain(id);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const c = scene.claims.get(id);
|
|
1328
|
+
if (c?.state.kind === "asking")
|
|
1329
|
+
c.state = UNCLAIMED;
|
|
1330
|
+
};
|
|
1331
|
+
/** `live.remove`: the claim dropped; the runtime asked to release it when this attach holds it (or asks it), else answered at once. */
|
|
1332
|
+
const remove = (id, reply) => {
|
|
1333
|
+
const c = scene.claims.get(id);
|
|
1334
|
+
scene.claims.delete(id);
|
|
1335
|
+
asking.delete(id);
|
|
1336
|
+
// A `live.upsert` of this id on the wire: its answer is not kept (granted, it is released).
|
|
1337
|
+
for (const f of firsts.values())
|
|
1338
|
+
if (f.named === id)
|
|
1339
|
+
f.keep = false;
|
|
1340
|
+
const held = !c || c.state.kind === "claimed" || c.state.kind === "asking";
|
|
1341
|
+
if (!session || !bound || !held) {
|
|
1342
|
+
reply.resolve();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
let call;
|
|
1346
|
+
try {
|
|
1347
|
+
call = startOn(node, session, "synapse/scene/live/remove", { id }, {});
|
|
1348
|
+
}
|
|
1349
|
+
catch (e) {
|
|
1350
|
+
reply.reject(asError(e));
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
void call.result.then(() => reply.resolve(), (e) => reply.reject(asError(e)));
|
|
1354
|
+
};
|
|
1355
|
+
/** The rows parked for `id` go out now, as one batch. */
|
|
1356
|
+
const sendParked = (id) => {
|
|
1357
|
+
const c = scene.claims.get(id);
|
|
1358
|
+
if (!c || !session)
|
|
1359
|
+
return;
|
|
1360
|
+
const p = c.parked;
|
|
1361
|
+
c.parked = {};
|
|
1362
|
+
const body = laneBody(p.pose ? [p.pose] : [], p.joints ? [p.joints] : [], p.state ? [p.state] : [], p.lifetimeMs);
|
|
1363
|
+
if (!body)
|
|
1364
|
+
return;
|
|
1365
|
+
// Not sent (the session is going): its end resets the claims, the rows are not re-sent (a batch is never re-sent).
|
|
1366
|
+
try {
|
|
1367
|
+
writeLive(liveWriterOn(scene, session), encodeLive(body));
|
|
1368
|
+
}
|
|
1369
|
+
catch { /* the session's end follows */ }
|
|
1370
|
+
};
|
|
1371
|
+
/** The runtime answered a claim asked in attach `b`. */
|
|
1372
|
+
const claimDone = (ask, b, result) => {
|
|
1373
|
+
if (ask.kind === "first") {
|
|
1374
|
+
let r;
|
|
1375
|
+
if ("ok" in result) {
|
|
1376
|
+
try {
|
|
1377
|
+
r = { ok: replyId(result.ok) };
|
|
1378
|
+
}
|
|
1379
|
+
catch (e) {
|
|
1380
|
+
r = { err: asError(e) };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
else
|
|
1384
|
+
r = result;
|
|
1385
|
+
const f = firsts.get(ask.token);
|
|
1386
|
+
firsts.delete(ask.token);
|
|
1387
|
+
const keep = (f?.keep ?? false) && mirror.state !== "closed";
|
|
1388
|
+
if ("ok" in r && !keep) {
|
|
1389
|
+
// Not kept: a `live.remove` of its id or the handle's close came after it. A grant is released; nothing is kept.
|
|
1390
|
+
release(node, session, r.ok);
|
|
1391
|
+
if (mirror.state === "closed")
|
|
1392
|
+
ask.reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
|
|
1393
|
+
else
|
|
1394
|
+
ask.reply.resolve(r.ok);
|
|
1395
|
+
}
|
|
1396
|
+
else if ("ok" in r) {
|
|
1397
|
+
// Granted in an earlier attach: claimed again in this one.
|
|
1398
|
+
const here = b === bindNo;
|
|
1399
|
+
scene.claims.set(r.ok, new Claim(ask.args, here ? CLAIMED : UNCLAIMED));
|
|
1400
|
+
if (!here)
|
|
1401
|
+
askAgain(r.ok);
|
|
1402
|
+
ask.reply.resolve(r.ok);
|
|
1403
|
+
}
|
|
1404
|
+
else if (sessionGone(r.err)) {
|
|
1405
|
+
// The session went before the runtime answered: asked on the next attach (unless no longer wanted).
|
|
1406
|
+
if (!keep)
|
|
1407
|
+
ask.reply.reject(r.err);
|
|
1408
|
+
else if (b !== bindNo && bound)
|
|
1409
|
+
askFirst(ask.args, ask.reply);
|
|
1410
|
+
else
|
|
1411
|
+
parkedUpserts.push({ args: ask.args, reply: ask.reply });
|
|
1412
|
+
}
|
|
1413
|
+
else
|
|
1414
|
+
ask.reply.reject(r.err);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (b !== bindNo)
|
|
1418
|
+
return;
|
|
1419
|
+
const id = ask.id;
|
|
1420
|
+
asking.delete(id);
|
|
1421
|
+
const c = scene.claims.get(id);
|
|
1422
|
+
if (!c)
|
|
1423
|
+
return;
|
|
1424
|
+
if ("ok" in result) {
|
|
1425
|
+
c.state = CLAIMED;
|
|
1426
|
+
c.refusals = 0;
|
|
1427
|
+
// The rows parked while it was asked go out now.
|
|
1428
|
+
sendParked(id);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
const e = result.err;
|
|
1432
|
+
if (sessionGone(e)) {
|
|
1433
|
+
c.state = UNCLAIMED;
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
// The runtime is mid-change: asked again shortly, silently (rows keep parking).
|
|
1437
|
+
if (isTransient(e)) {
|
|
1438
|
+
c.state = { kind: "paced", at: performance.now() + REFOLLOW_PACE_MS, refused: false };
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
c.refusals += 1;
|
|
1442
|
+
const pace = refusalPaceMs(c.refusals);
|
|
1443
|
+
scene.warn(`scene: live claim of ${id} refused by the runtime (${e.code}${e.message ? `: ${e.message}` : ""}; attempt ${c.refusals}; asking again in ${pace} ms)`);
|
|
1444
|
+
c.state = { kind: "paced", at: performance.now() + pace, refused: true };
|
|
1445
|
+
const dropped = parkedRows(c.parked);
|
|
1446
|
+
c.parked = {};
|
|
1447
|
+
if (dropped) {
|
|
1448
|
+
scene.unclaimedDrops += dropped;
|
|
1449
|
+
scene.countersChanged();
|
|
1450
|
+
}
|
|
1451
|
+
// An override's refusal is the writer's to see: a node notice (a `not_owner` reaches the denial feeds through it).
|
|
1452
|
+
if (!c.isEntity) {
|
|
1453
|
+
const body = { code: e.code };
|
|
1454
|
+
if (e.message)
|
|
1455
|
+
body.message = e.message;
|
|
1456
|
+
if (e.data !== undefined)
|
|
1457
|
+
body.data = e.data;
|
|
1458
|
+
for (const cb of [...node.notices]) {
|
|
1459
|
+
try {
|
|
1460
|
+
cb(body);
|
|
1461
|
+
}
|
|
1462
|
+
catch (x) {
|
|
1463
|
+
rethrowLater(x);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
/** The task ends: every claim held (or asked) in this attach is released by the call `synapse/scene/live/remove {id}`, one call per claim, best effort (not awaited); the claims are dropped. The session's end releases whatever remains. */
|
|
1469
|
+
const releaseClaims = () => {
|
|
1470
|
+
for (const f of firsts.values())
|
|
1471
|
+
f.keep = false;
|
|
1472
|
+
const claims = [...scene.claims];
|
|
1473
|
+
scene.claims.clear();
|
|
1474
|
+
if (!session || !bound)
|
|
1475
|
+
return;
|
|
1476
|
+
for (const [id, c] of claims) {
|
|
1477
|
+
if (c.state.kind !== "claimed" && c.state.kind !== "asking")
|
|
1478
|
+
continue;
|
|
1479
|
+
try {
|
|
1480
|
+
void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
|
|
1481
|
+
}
|
|
1482
|
+
catch { /* the session's end releases it */ }
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
/** The refused live entities that are due are asked again. */
|
|
1486
|
+
const reclaimDue = () => {
|
|
1487
|
+
const now = performance.now();
|
|
1488
|
+
for (const [id, c] of [...scene.claims]) {
|
|
1489
|
+
const at = timerDue(c);
|
|
1490
|
+
if (at !== undefined && at <= now)
|
|
1491
|
+
askAgain(id);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
/** The timer for the next refused live entity due to be asked again (none while unbound or nothing paced). */
|
|
1495
|
+
const armReclaim = () => {
|
|
1496
|
+
let at = Infinity;
|
|
1497
|
+
if (bound)
|
|
1498
|
+
for (const c of scene.claims.values()) {
|
|
1499
|
+
const due = timerDue(c);
|
|
1500
|
+
if (due !== undefined && due < at)
|
|
1501
|
+
at = due;
|
|
1502
|
+
}
|
|
1503
|
+
if (at === reclaimAt)
|
|
1504
|
+
return;
|
|
1505
|
+
if (reclaimTimer !== undefined)
|
|
1506
|
+
clearTimeout(reclaimTimer);
|
|
1507
|
+
reclaimTimer = undefined;
|
|
1508
|
+
reclaimAt = at;
|
|
1509
|
+
if (at !== Infinity)
|
|
1510
|
+
reclaimTimer = setTimeout(() => { reclaimAt = Infinity; reclaimTimer = undefined; scene.post({ kind: "reclaim" }); }, Math.max(0, at - performance.now()));
|
|
859
1511
|
};
|
|
860
1512
|
const join = (reply) => { if (mirror.state === "attached" || inactive())
|
|
861
1513
|
reply.resolve();
|
|
@@ -921,7 +1573,6 @@ async function run(scene, registry) {
|
|
|
921
1573
|
const e = result.err;
|
|
922
1574
|
const live = e.data?.live_epoch;
|
|
923
1575
|
if (e.code === "conflict" && typeof live === "string" && live !== epoch && mirror.epoch === epoch) {
|
|
924
|
-
endedEpoch = epoch;
|
|
925
1576
|
again(REFOLLOW_PACE_MS);
|
|
926
1577
|
}
|
|
927
1578
|
reply.reject(e);
|
|
@@ -957,6 +1608,13 @@ async function run(scene, registry) {
|
|
|
957
1608
|
case "applied":
|
|
958
1609
|
applied(ev.epoch, ev.version);
|
|
959
1610
|
break;
|
|
1611
|
+
case "opened":
|
|
1612
|
+
opened(ev.attempt, ev.result);
|
|
1613
|
+
break;
|
|
1614
|
+
case "liveKeyed":
|
|
1615
|
+
if (wanted?.epoch === ev.epoch)
|
|
1616
|
+
attach();
|
|
1617
|
+
break;
|
|
960
1618
|
case "followEnded": {
|
|
961
1619
|
{
|
|
962
1620
|
const p = ph();
|
|
@@ -964,9 +1622,6 @@ async function run(scene, registry) {
|
|
|
964
1622
|
break;
|
|
965
1623
|
}
|
|
966
1624
|
phase = { kind: "idle" };
|
|
967
|
-
// An incarnation that ended with its final frame is not bound again.
|
|
968
|
-
if (ev.epoch !== undefined && ev.reason !== undefined)
|
|
969
|
-
endedEpoch = ev.epoch;
|
|
970
1625
|
again(REFOLLOW_PACE_MS);
|
|
971
1626
|
break;
|
|
972
1627
|
}
|
|
@@ -980,9 +1635,7 @@ async function run(scene, registry) {
|
|
|
980
1635
|
applyDone(ev.epoch, ev.result, ev.reply);
|
|
981
1636
|
break;
|
|
982
1637
|
case "project":
|
|
983
|
-
|
|
984
|
-
if (inactive())
|
|
985
|
-
answerJoins();
|
|
1638
|
+
projectChanged(ev.snapshot);
|
|
986
1639
|
break;
|
|
987
1640
|
case "notice":
|
|
988
1641
|
notice(ev.body);
|
|
@@ -990,14 +1643,36 @@ async function run(scene, registry) {
|
|
|
990
1643
|
case "paced":
|
|
991
1644
|
if (ph().kind === "paced" && ev.attempt === attempts) {
|
|
992
1645
|
phase = { kind: "idle" };
|
|
993
|
-
|
|
1646
|
+
open();
|
|
994
1647
|
}
|
|
995
1648
|
break;
|
|
1649
|
+
case "upsert":
|
|
1650
|
+
upsert(ev.args, ev.reply);
|
|
1651
|
+
break;
|
|
1652
|
+
case "remove":
|
|
1653
|
+
remove(ev.id, ev.reply);
|
|
1654
|
+
break;
|
|
1655
|
+
case "claim":
|
|
1656
|
+
claimAsked(ev.id);
|
|
1657
|
+
break;
|
|
1658
|
+
case "claimDone":
|
|
1659
|
+
claimDone(ev.ask, ev.bind, ev.result);
|
|
1660
|
+
break;
|
|
1661
|
+
case "reclaim":
|
|
1662
|
+
reclaimDue();
|
|
1663
|
+
break;
|
|
996
1664
|
case "close": break loop;
|
|
997
1665
|
}
|
|
1666
|
+
armReclaim();
|
|
998
1667
|
}
|
|
999
1668
|
}
|
|
1000
1669
|
finally {
|
|
1670
|
+
// The last handle closed (or the node ended): this node's claims are released (best effort).
|
|
1671
|
+
releaseClaims();
|
|
1672
|
+
if (reclaimTimer !== undefined)
|
|
1673
|
+
clearTimeout(reclaimTimer);
|
|
1674
|
+
for (const u of parkedUpserts.splice(0))
|
|
1675
|
+
u.reply.reject(closedError(reason));
|
|
1001
1676
|
unmonitor();
|
|
1002
1677
|
node.notices.delete(onNotice);
|
|
1003
1678
|
unwatchProject?.();
|
|
@@ -1016,13 +1691,18 @@ async function run(scene, registry) {
|
|
|
1016
1691
|
break;
|
|
1017
1692
|
if (ev.kind === "applyDone")
|
|
1018
1693
|
applyDone(ev.epoch, ev.result, ev.reply);
|
|
1019
|
-
else if (ev.kind === "apply" || ev.kind === "join")
|
|
1694
|
+
else if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "upsert" || ev.kind === "remove")
|
|
1020
1695
|
ev.reply.reject(closedError(reason));
|
|
1696
|
+
else if (ev.kind === "claimDone")
|
|
1697
|
+
claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
|
|
1021
1698
|
}
|
|
1022
1699
|
void scene.mailbox.close();
|
|
1023
|
-
for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext())
|
|
1024
|
-
if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone")
|
|
1700
|
+
for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext()) {
|
|
1701
|
+
if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone" || ev.kind === "upsert" || ev.kind === "remove")
|
|
1025
1702
|
ev.reply.reject(closedError(reason));
|
|
1703
|
+
else if (ev.kind === "claimDone")
|
|
1704
|
+
claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
|
|
1705
|
+
}
|
|
1026
1706
|
if (registry.current === scene)
|
|
1027
1707
|
registry.current = undefined;
|
|
1028
1708
|
scene.exited.resolve();
|
|
@@ -1035,80 +1715,33 @@ function announcedEpoch(prefix) {
|
|
|
1035
1715
|
const e = prefix.slice(SCENE_BASE.length + 2);
|
|
1036
1716
|
return e && !e.includes("/") ? e : undefined;
|
|
1037
1717
|
}
|
|
1038
|
-
/**
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
async first() {
|
|
1054
|
-
try {
|
|
1055
|
-
const g = await this.sub.recvGroup();
|
|
1056
|
-
if (!g)
|
|
1057
|
-
return undefined;
|
|
1058
|
-
this.group = g;
|
|
1059
|
-
return (await g.readFrame())?.payload;
|
|
1718
|
+
/**
|
|
1719
|
+
* Reads track `name` of `bc` frame by frame as frames come until `on` says `end`, the track ends or `signal` aborts. A
|
|
1720
|
+
* `gap`: this subscription is let go and the track subscribed again, which delivers the open group from its snapshot
|
|
1721
|
+
* (PLAN v0.34 §2.2; no counter, no timer).
|
|
1722
|
+
*/
|
|
1723
|
+
async function readTrack(bc, name, signal, on) {
|
|
1724
|
+
const track = bc.track(name);
|
|
1725
|
+
for (;;) {
|
|
1726
|
+
if (signal.aborted)
|
|
1727
|
+
return;
|
|
1728
|
+
const sub = track.subscribe(latestSubscription(moqPriority(1)));
|
|
1729
|
+
// Closing the subscriber ends `recvGroup`, not a read on the group it already handed us: that group is closed too.
|
|
1730
|
+
let g;
|
|
1731
|
+
const close = () => { try {
|
|
1732
|
+
g?.close();
|
|
1060
1733
|
}
|
|
1061
|
-
catch {
|
|
1062
|
-
|
|
1734
|
+
catch { /* closed */ } try {
|
|
1735
|
+
sub.close();
|
|
1063
1736
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
for (;;) {
|
|
1068
|
-
const g = this.group;
|
|
1069
|
-
if (g) {
|
|
1070
|
-
let f;
|
|
1071
|
-
try {
|
|
1072
|
-
f = g.tryReadFrame();
|
|
1073
|
-
}
|
|
1074
|
-
catch {
|
|
1075
|
-
f = undefined;
|
|
1076
|
-
this.group = undefined;
|
|
1077
|
-
}
|
|
1078
|
-
if (f) {
|
|
1079
|
-
if (on(f.payload))
|
|
1080
|
-
continue;
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
if (this.group && g.done)
|
|
1084
|
-
this.group = undefined;
|
|
1085
|
-
}
|
|
1086
|
-
let next;
|
|
1087
|
-
try {
|
|
1088
|
-
next = this.sub.tryRecvGroup();
|
|
1089
|
-
}
|
|
1090
|
-
catch {
|
|
1091
|
-
next = undefined;
|
|
1092
|
-
}
|
|
1093
|
-
if (!next)
|
|
1094
|
-
return;
|
|
1095
|
-
try {
|
|
1096
|
-
this.group?.close();
|
|
1097
|
-
}
|
|
1098
|
-
catch { /* closed */ }
|
|
1099
|
-
this.group = next;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
/** Reads frame by frame as frames come, from the current group on, until `on` returns `false` or the track ends. */
|
|
1103
|
-
async readOn(on) {
|
|
1737
|
+
catch { /* closed */ } };
|
|
1738
|
+
signal.addEventListener("abort", close, { once: true });
|
|
1739
|
+
let read = "end";
|
|
1104
1740
|
try {
|
|
1105
|
-
for (;;) {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
const g = this.group;
|
|
1741
|
+
groups: for (;;) {
|
|
1742
|
+
g = await sub.recvGroup();
|
|
1743
|
+
if (!g)
|
|
1744
|
+
break;
|
|
1112
1745
|
for (;;) {
|
|
1113
1746
|
let f;
|
|
1114
1747
|
try {
|
|
@@ -1119,13 +1752,22 @@ class TrackReading {
|
|
|
1119
1752
|
}
|
|
1120
1753
|
if (!f)
|
|
1121
1754
|
break;
|
|
1122
|
-
|
|
1123
|
-
|
|
1755
|
+
read = on(f.payload);
|
|
1756
|
+
if (read !== "read")
|
|
1757
|
+
break groups;
|
|
1124
1758
|
}
|
|
1125
|
-
|
|
1759
|
+
read = "end";
|
|
1126
1760
|
}
|
|
1127
1761
|
}
|
|
1128
|
-
catch {
|
|
1762
|
+
catch {
|
|
1763
|
+
read = "end"; /* the track went */
|
|
1764
|
+
}
|
|
1765
|
+
finally {
|
|
1766
|
+
signal.removeEventListener("abort", close);
|
|
1767
|
+
close();
|
|
1768
|
+
}
|
|
1769
|
+
if (read !== "gap")
|
|
1770
|
+
return;
|
|
1129
1771
|
}
|
|
1130
1772
|
}
|
|
1131
1773
|
/** A frame's header flags and decoded body, or `undefined` (warned) when it does not decode. */
|
|
@@ -1140,14 +1782,18 @@ function decodeFrame(p, warn, what) {
|
|
|
1140
1782
|
}
|
|
1141
1783
|
}
|
|
1142
1784
|
/**
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1785
|
+
* Reads incarnation `epoch` (the one `synapse/scene/open` answered) once it is announced: both its tracks into the
|
|
1786
|
+
* mirror, frame by frame as they come, telling the task of each authored frame applied and of the live track's first
|
|
1787
|
+
* keyframe (the task attaches the handle once the copy is current: a state of the copy, not a count of frames read);
|
|
1788
|
+
* returns once the `authored` track ends (its final frame, or the track going). Once `signal` aborts, or it returned,
|
|
1789
|
+
* it writes nothing more.
|
|
1146
1790
|
*/
|
|
1147
|
-
async function followIncarnation(scene, session, announced,
|
|
1791
|
+
async function followIncarnation(scene, session, announced, epoch, signal) {
|
|
1148
1792
|
const { mirror, warn } = scene;
|
|
1149
1793
|
const none = { epoch: undefined, reason: undefined };
|
|
1150
|
-
|
|
1794
|
+
// Its readers stop with the task's signal, and when this returns.
|
|
1795
|
+
const reading = new AbortController();
|
|
1796
|
+
const closers = [announced, { close: () => reading.abort() }];
|
|
1151
1797
|
const closeAll = () => { for (const c of closers) {
|
|
1152
1798
|
try {
|
|
1153
1799
|
c.close();
|
|
@@ -1155,36 +1801,38 @@ async function followIncarnation(scene, session, announced, skip, signal) {
|
|
|
1155
1801
|
catch { /* closed */ }
|
|
1156
1802
|
} };
|
|
1157
1803
|
signal.addEventListener("abort", closeAll, { once: true });
|
|
1804
|
+
const stopped = () => reading.signal.aborted;
|
|
1158
1805
|
try {
|
|
1159
|
-
|
|
1160
|
-
for
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
break;
|
|
1806
|
+
// The incarnation the open named, once announced: within the store's bound (an announce/retract pair the cursor never
|
|
1807
|
+
// saw is gone for good; the task opens again).
|
|
1808
|
+
const deadline = setTimeout(() => announced.close(), ANNOUNCE_WITHIN_MS);
|
|
1809
|
+
try {
|
|
1810
|
+
for (;;) {
|
|
1811
|
+
const u = await announced.next();
|
|
1812
|
+
if (!u || stopped())
|
|
1813
|
+
return none;
|
|
1814
|
+
if (u.kind !== "retracted" && announcedEpoch(String(u.prefix)) === epoch)
|
|
1815
|
+
break;
|
|
1170
1816
|
}
|
|
1171
1817
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1818
|
+
finally {
|
|
1819
|
+
clearTimeout(deadline);
|
|
1820
|
+
}
|
|
1821
|
+
// Resolving the broadcast races an end of this epoch already on the cursor (opened before the open call).
|
|
1822
|
+
const req = session.request(`${SCENE_BASE}/@${epoch}`);
|
|
1175
1823
|
closers.push(req);
|
|
1176
1824
|
const retracted = (async () => { for (;;) {
|
|
1177
1825
|
const u = await announced.next();
|
|
1178
1826
|
if (!u)
|
|
1179
1827
|
return false;
|
|
1180
|
-
if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) ===
|
|
1828
|
+
if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === epoch)
|
|
1181
1829
|
return true;
|
|
1182
1830
|
} })();
|
|
1183
1831
|
let bc;
|
|
1184
1832
|
try {
|
|
1185
|
-
const r = await Promise.race([waitActive(req, 10_000, signal).then(b => ({ bc: b })), retracted.then(gone => ({ gone }))]);
|
|
1833
|
+
const r = await Promise.race([waitActive(req, 10_000, reading.signal).then(b => ({ bc: b })), retracted.then(gone => ({ gone }))]);
|
|
1186
1834
|
if ("gone" in r)
|
|
1187
|
-
return r.gone ? { epoch
|
|
1835
|
+
return r.gone ? { epoch, reason: "ended" } : none;
|
|
1188
1836
|
bc = r.bc;
|
|
1189
1837
|
}
|
|
1190
1838
|
catch {
|
|
@@ -1192,88 +1840,67 @@ async function followIncarnation(scene, session, announced, skip, signal) {
|
|
|
1192
1840
|
}
|
|
1193
1841
|
announced.close();
|
|
1194
1842
|
closers.push(bc);
|
|
1195
|
-
|
|
1196
|
-
const live = new TrackReading(bc.track(LIVE_TRACK).subscribe(latestSubscription(moqPriority(1))));
|
|
1197
|
-
closers.push(authored, live);
|
|
1198
|
-
if (signal.aborted)
|
|
1843
|
+
if (stopped())
|
|
1199
1844
|
return none;
|
|
1200
1845
|
let end;
|
|
1201
|
-
|
|
1846
|
+
let keyed = false;
|
|
1202
1847
|
const onAuthored = (p) => {
|
|
1203
|
-
if (
|
|
1204
|
-
return
|
|
1848
|
+
if (stopped())
|
|
1849
|
+
return "end";
|
|
1205
1850
|
const f = decodeFrame(p, warn, "authored");
|
|
1206
1851
|
if (!f)
|
|
1207
|
-
return
|
|
1852
|
+
return "read";
|
|
1208
1853
|
if (f.flags & FLAGS.END) {
|
|
1209
1854
|
const r = f.body?.reason;
|
|
1210
1855
|
end = typeof r === "string" ? r : "ended";
|
|
1211
|
-
return
|
|
1856
|
+
return "end";
|
|
1212
1857
|
}
|
|
1213
1858
|
let version;
|
|
1214
1859
|
if (f.flags & FLAGS.KEY) {
|
|
1215
1860
|
const s = f.body;
|
|
1216
1861
|
if (!s || typeof s.version !== "number" || !isObject(s.entities)) {
|
|
1217
1862
|
warn("scene: snapshot not understood");
|
|
1218
|
-
return
|
|
1863
|
+
return "read";
|
|
1219
1864
|
}
|
|
1220
|
-
mirror.applySnapshot(
|
|
1865
|
+
mirror.applySnapshot(epoch, s);
|
|
1221
1866
|
version = s.version;
|
|
1222
1867
|
}
|
|
1223
1868
|
else if (f.flags & FLAGS.DELTA) {
|
|
1224
1869
|
const d = f.body;
|
|
1225
1870
|
if (!d || typeof d.version !== "number" || !Array.isArray(d.ops)) {
|
|
1226
1871
|
warn("scene: diff not understood");
|
|
1227
|
-
return
|
|
1872
|
+
return "read";
|
|
1228
1873
|
}
|
|
1229
|
-
mirror.applyDiff(d, warn)
|
|
1874
|
+
if (!mirror.applyDiff(d, warn))
|
|
1875
|
+
return "gap";
|
|
1230
1876
|
version = d.version;
|
|
1231
1877
|
}
|
|
1232
1878
|
else
|
|
1233
|
-
return
|
|
1234
|
-
scene.post({ kind: "applied", epoch
|
|
1235
|
-
return
|
|
1879
|
+
return "read";
|
|
1880
|
+
scene.post({ kind: "applied", epoch, version });
|
|
1881
|
+
return "read";
|
|
1236
1882
|
};
|
|
1237
1883
|
const onLive = (p) => {
|
|
1238
|
-
if (
|
|
1239
|
-
return
|
|
1884
|
+
if (stopped())
|
|
1885
|
+
return "end";
|
|
1240
1886
|
const f = decodeFrame(p, warn, "live");
|
|
1241
1887
|
if (!f || !isObject(f.body))
|
|
1242
|
-
return
|
|
1243
|
-
if (f.flags & FLAGS.KEY)
|
|
1888
|
+
return "read";
|
|
1889
|
+
if (f.flags & FLAGS.KEY) {
|
|
1244
1890
|
mirror.applyKeyframe(f.body);
|
|
1891
|
+
if (!keyed) {
|
|
1892
|
+
keyed = true;
|
|
1893
|
+
scene.post({ kind: "liveKeyed", epoch });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1245
1896
|
else if (f.flags & FLAGS.DELTA)
|
|
1246
1897
|
mirror.applyLiveTick(f.body);
|
|
1247
|
-
return
|
|
1898
|
+
return "read";
|
|
1248
1899
|
};
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
if (signal.aborted)
|
|
1254
|
-
return none;
|
|
1255
|
-
if (!a0)
|
|
1256
|
-
return ended();
|
|
1257
|
-
if (!onAuthored(a0))
|
|
1258
|
-
return ended();
|
|
1259
|
-
const l0 = await live.first();
|
|
1260
|
-
if (signal.aborted)
|
|
1261
|
-
return none;
|
|
1262
|
-
if (!l0)
|
|
1263
|
-
return ended();
|
|
1264
|
-
onLive(l0);
|
|
1265
|
-
authored.drain(onAuthored);
|
|
1266
|
-
if (end !== undefined)
|
|
1267
|
-
return ended();
|
|
1268
|
-
live.drain(onLive);
|
|
1269
|
-
if (signal.aborted)
|
|
1270
|
-
return none;
|
|
1271
|
-
mirror.attach();
|
|
1272
|
-
scene.post({ kind: "applied", epoch: at, version: mirror.version });
|
|
1273
|
-
// Then frame by frame as they come. The live track's end is the incarnation's: the authored track says how it ended.
|
|
1274
|
-
void live.readOn(onLive);
|
|
1275
|
-
await authored.readOn(onAuthored);
|
|
1276
|
-
return ended();
|
|
1900
|
+
// The live track's end is the incarnation's: the authored track says how it ended.
|
|
1901
|
+
void readTrack(bc, LIVE_TRACK, reading.signal, onLive);
|
|
1902
|
+
await readTrack(bc, AUTHORED_TRACK, reading.signal, onAuthored);
|
|
1903
|
+
return stopped() ? none : { epoch, reason: end };
|
|
1277
1904
|
}
|
|
1278
1905
|
catch {
|
|
1279
1906
|
return none;
|
|
@@ -1316,34 +1943,180 @@ export class SceneHandleImpl {
|
|
|
1316
1943
|
/** The views of this handle that follow the mirror now. */
|
|
1317
1944
|
#views = new Set();
|
|
1318
1945
|
#unsubscribes = new Set();
|
|
1946
|
+
/** `ready()` calls waiting for the handle to attach: rejected by `close()`. */
|
|
1947
|
+
#readies = new Set();
|
|
1948
|
+
/** Told when this handle closes (its status says `closed` from then on, whatever the node's other handles do). */
|
|
1949
|
+
#onClose = new Set();
|
|
1950
|
+
/** The authored plane's verbs. */
|
|
1951
|
+
authored;
|
|
1952
|
+
/** This node's live writes. */
|
|
1953
|
+
live;
|
|
1954
|
+
/** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
|
|
1955
|
+
status;
|
|
1956
|
+
#status;
|
|
1319
1957
|
constructor(scene) {
|
|
1320
1958
|
this.scene = scene;
|
|
1321
1959
|
scene.handles++;
|
|
1322
1960
|
this.#id = scene.nextHandle++;
|
|
1961
|
+
this.status = this.#status = new StatusCell(() => this.#statusNow(), l => {
|
|
1962
|
+
const un = scene.mirror.subscribe(l);
|
|
1963
|
+
scene.counters.add(l);
|
|
1964
|
+
this.#onClose.add(l);
|
|
1965
|
+
return () => { un(); scene.counters.delete(l); this.#onClose.delete(l); };
|
|
1966
|
+
}, () => this.#closed || scene.mirror.state === "closed");
|
|
1967
|
+
// One object each for the handle's life.
|
|
1968
|
+
this.authored = Object.freeze({
|
|
1969
|
+
apply: (ops, base) => this.#apply(ops, base),
|
|
1970
|
+
txn: (build) => this.#txn(build),
|
|
1971
|
+
spawn: (kind, opts) => this.#spawn(kind, opts),
|
|
1972
|
+
patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
|
|
1973
|
+
upsert: (kind, opts) => this.#upsert(kind, opts),
|
|
1974
|
+
rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
|
|
1975
|
+
reparent: (entity, parent, place) => this.#txn(t => t.reparent(entity, parent, place)),
|
|
1976
|
+
despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
|
|
1977
|
+
setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
|
|
1978
|
+
});
|
|
1979
|
+
this.live = Object.freeze({
|
|
1980
|
+
batch: (batch) => this.#batch(batch),
|
|
1981
|
+
upsert: (kind, opts, live) => this.#liveUpsert(kind, opts ?? {}, live ?? {}),
|
|
1982
|
+
remove: (entity) => { try {
|
|
1983
|
+
return this.#releaseId(idOf(entity));
|
|
1984
|
+
}
|
|
1985
|
+
catch (e) {
|
|
1986
|
+
return Promise.reject(e);
|
|
1987
|
+
} },
|
|
1988
|
+
});
|
|
1323
1989
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1990
|
+
/** The merged entities now; a closed handle reads nothing (the mirror may live on for the node's other handles). */
|
|
1991
|
+
get() { return this.#closed ? EMPTY : this.scene.mirror.merged; }
|
|
1992
|
+
/** One entity as a view: the merged entity, with its authored value (`authored`) and its live row (`live`). */
|
|
1993
|
+
entity(ref) {
|
|
1326
1994
|
const mirror = this.scene.mirror;
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1995
|
+
const named = this.#idOf(ref);
|
|
1996
|
+
const id = () => {
|
|
1997
|
+
const id = named();
|
|
1998
|
+
if (id === undefined)
|
|
1999
|
+
throw notNamed(ref);
|
|
2000
|
+
return id;
|
|
2001
|
+
};
|
|
2002
|
+
const ended = () => this.#closed || mirror.state === "closed";
|
|
2003
|
+
// Every read goes through the same guard: a closed handle reads nothing (the mirror may live on for the node's other handles).
|
|
2004
|
+
const reader = (read) => () => {
|
|
2005
|
+
if (ended())
|
|
2006
|
+
return undefined;
|
|
2007
|
+
const id = named();
|
|
2008
|
+
return id === undefined ? undefined : read(id);
|
|
2009
|
+
};
|
|
2010
|
+
// The live row, rebuilt only when the row object changes and kept the same object while equal.
|
|
2011
|
+
let from;
|
|
2012
|
+
let last;
|
|
2013
|
+
const ops = {
|
|
2014
|
+
id,
|
|
2015
|
+
// The entity as declared (the authored one, or a live entity's upsert row): its object changes only when an
|
|
2016
|
+
// authored batch or a new upsert changes it, never on a lane tick.
|
|
2017
|
+
readAuthored: reader(id => mirror.authored.get(id) ?? mirror.live.get(id)?.entity),
|
|
2018
|
+
readLive: reader(id => {
|
|
1336
2019
|
const row = mirror.live.get(id);
|
|
1337
2020
|
if (row === from)
|
|
1338
2021
|
return last;
|
|
1339
2022
|
from = row;
|
|
1340
2023
|
last = row ? replaceEqualDeep(last, liveValue(row)) : undefined;
|
|
1341
2024
|
return last;
|
|
1342
|
-
})
|
|
2025
|
+
}),
|
|
2026
|
+
write: components => this.#write(ref, named, components),
|
|
2027
|
+
live: (components, opts) => this.#liveLanes(ref, named, components, opts),
|
|
2028
|
+
remove: () => this.#liveRemove(ref, named),
|
|
2029
|
+
};
|
|
2030
|
+
return new EntityView(mirror, reader(id => mirror.merged[id]), this.#views, ended, ops);
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* The id a view names now: its id, or the id of the entity holding its name (names are unique across every plane;
|
|
2034
|
+
* an authored or calibration entity, else a live entity). The last id found is checked first: a name is held by one
|
|
2035
|
+
* entity at a time.
|
|
2036
|
+
*/
|
|
2037
|
+
#idOf(ref) {
|
|
2038
|
+
if (typeof ref === "string")
|
|
2039
|
+
return () => ref;
|
|
2040
|
+
if (!isObject(ref) || typeof ref.name !== "string")
|
|
2041
|
+
throw new SynapseError("invalid_input", "scene.entity takes an id or { name }");
|
|
2042
|
+
const name = ref.name, mirror = this.scene.mirror;
|
|
2043
|
+
let held;
|
|
2044
|
+
return () => {
|
|
2045
|
+
if (held !== undefined && (mirror.authored.get(held) ?? mirror.live.get(held)?.entity)?.name === name)
|
|
2046
|
+
return held;
|
|
2047
|
+
held = authoredNamed(mirror.authored, name)?.id ?? liveNamed(mirror.live, name);
|
|
2048
|
+
return held;
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
/** An entity view's `authored.set`: one patch of the id it names now. A name no entity holds has nothing to send: `not_found`, as the runtime answers a patch of an unknown id. */
|
|
2052
|
+
async #write(ref, named, components) {
|
|
2053
|
+
if (this.#closed)
|
|
2054
|
+
throw closedError(ENDED_CLIENT);
|
|
2055
|
+
if (this.scene.mirror.state === "detached")
|
|
2056
|
+
throw detachedError();
|
|
2057
|
+
const id = named();
|
|
2058
|
+
if (id === undefined)
|
|
2059
|
+
throw notNamed(ref);
|
|
2060
|
+
await this.#txn(t => t.patch(id, components));
|
|
2061
|
+
}
|
|
2062
|
+
/** An entity view's `live.set`: the lanes of the id it names now, as one batch. */
|
|
2063
|
+
#liveLanes(ref, named, components, opts) {
|
|
2064
|
+
this.#liveOpen();
|
|
2065
|
+
const lanes = {};
|
|
2066
|
+
for (const [k, v] of Object.entries(components)) {
|
|
2067
|
+
if (v === undefined)
|
|
2068
|
+
continue;
|
|
2069
|
+
if (k === "xform") {
|
|
2070
|
+
const x = v;
|
|
2071
|
+
if (!isObject(v) || !("t" in x) || !("q" in x) || Object.keys(x).some(k => k !== "t" && k !== "q"))
|
|
2072
|
+
throw new SynapseError("invalid_input", "scene: live xform is the pose lane: { t, q }, both, nothing else");
|
|
2073
|
+
}
|
|
2074
|
+
else if (k !== "joints" && k !== "state")
|
|
2075
|
+
throw new SynapseError("invalid_input", { message: `scene: live.set(...) writes the lanes xform ({ t, q }), joints and state, not ${JSON.stringify(k)}`, data: { component: k } });
|
|
2076
|
+
lanes[k] = v;
|
|
1343
2077
|
}
|
|
1344
|
-
|
|
2078
|
+
const id = named();
|
|
2079
|
+
if (id === undefined) {
|
|
2080
|
+
// Detached, nothing is named: the write is dropped and counted, as every live write then is.
|
|
2081
|
+
if (this.scene.mirror.state !== "attached") {
|
|
2082
|
+
this.scene.detachedDrops++;
|
|
2083
|
+
this.scene.countersChanged();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
throw notNamed(ref);
|
|
2087
|
+
}
|
|
2088
|
+
const batch = {};
|
|
2089
|
+
const x = lanes.xform;
|
|
2090
|
+
if (x)
|
|
2091
|
+
batch.poses = [{ id, t: x.t, q: x.q }];
|
|
2092
|
+
if (lanes.joints !== undefined)
|
|
2093
|
+
batch.joints = [{ id, j: lanes.joints }];
|
|
2094
|
+
if (lanes.state !== undefined)
|
|
2095
|
+
batch.state = [{ id, s: lanes.state }];
|
|
2096
|
+
if (opts.lifetimeMs !== undefined)
|
|
2097
|
+
batch.lifetimeMs = opts.lifetimeMs;
|
|
2098
|
+
this.#batch(batch);
|
|
2099
|
+
}
|
|
2100
|
+
/** An entity view's `live.remove`: the release call of the id it names now (`scene.live.remove(id)`); a view by name whose name no entity holds has no id to release: `not_found`. */
|
|
2101
|
+
#liveRemove(ref, named) {
|
|
2102
|
+
try {
|
|
2103
|
+
this.#liveOpen();
|
|
2104
|
+
const id = named();
|
|
2105
|
+
if (id === undefined)
|
|
2106
|
+
throw notNamed(ref);
|
|
2107
|
+
return this.#releaseId(id);
|
|
2108
|
+
}
|
|
2109
|
+
catch (e) {
|
|
2110
|
+
return Promise.reject(e);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/** `live.remove`: the call `synapse/scene/live/remove {id}` through the task (it drops the claim). */
|
|
2114
|
+
#releaseId(id) {
|
|
2115
|
+
if (this.#closed)
|
|
2116
|
+
return Promise.reject(closedError(ENDED_CLIENT));
|
|
2117
|
+
return this.scene.request(reply => ({ kind: "remove", id, reply }));
|
|
1345
2118
|
}
|
|
1346
|
-
tree() { return this.#view(() => this.scene.mirror.tree()); }
|
|
2119
|
+
tree() { return this.#view(() => this.scene.mirror.tree(), EMPTY_TREE); }
|
|
1347
2120
|
query(where) {
|
|
1348
2121
|
let from;
|
|
1349
2122
|
let last = [];
|
|
@@ -1355,13 +2128,40 @@ export class SceneHandleImpl {
|
|
|
1355
2128
|
const ids = Object.keys(m).filter(id => where(m[id])).sort();
|
|
1356
2129
|
last = replaceEqualDeep(last, ids.map(id => m[id]));
|
|
1357
2130
|
return last;
|
|
1358
|
-
});
|
|
2131
|
+
}, EMPTY_LIST);
|
|
2132
|
+
}
|
|
2133
|
+
/** A view of `read`; a closed handle reads `empty` (the mirror may live on for the node's other handles), as its entity views and `get()` do. */
|
|
2134
|
+
#view(read, empty) {
|
|
2135
|
+
const ended = () => this.#closed || this.scene.mirror.state === "closed";
|
|
2136
|
+
return new MirrorView(this.scene.mirror, () => (ended() ? empty : read()), this.#views, ended);
|
|
1359
2137
|
}
|
|
1360
|
-
#
|
|
1361
|
-
get status() {
|
|
2138
|
+
#statusNow() {
|
|
1362
2139
|
const m = this.scene.mirror;
|
|
1363
2140
|
const state = this.#closed ? "closed" : m.state;
|
|
1364
|
-
return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 &&
|
|
2141
|
+
return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 && m.gap === undefined, pending: m.pending, version: m.version, hash: m.hash, detachedDrops: this.scene.detachedDrops, unclaimedDrops: this.scene.unclaimedDrops };
|
|
2142
|
+
}
|
|
2143
|
+
/** The one boolean a node author checks: `status.get().synced`. */
|
|
2144
|
+
get synced() { return this.status.get().synced; }
|
|
2145
|
+
/** Resolves once the handle is attached (at once if it is); `not_connected` once it is closed (`close()`, or the node ending). */
|
|
2146
|
+
ready() {
|
|
2147
|
+
const m = this.scene.mirror;
|
|
2148
|
+
if (this.#closed || m.state === "closed")
|
|
2149
|
+
return Promise.reject(closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT));
|
|
2150
|
+
if (m.state === "attached")
|
|
2151
|
+
return Promise.resolve();
|
|
2152
|
+
const d = deferred();
|
|
2153
|
+
this.#readies.add(d);
|
|
2154
|
+
const un = this.onChange(() => {
|
|
2155
|
+
if (m.state === "closed")
|
|
2156
|
+
d.reject(closedError(m.ended ?? ENDED_CLIENT));
|
|
2157
|
+
else if (m.state === "attached")
|
|
2158
|
+
d.resolve();
|
|
2159
|
+
else
|
|
2160
|
+
return;
|
|
2161
|
+
un();
|
|
2162
|
+
this.#readies.delete(d);
|
|
2163
|
+
});
|
|
2164
|
+
return d.promise;
|
|
1365
2165
|
}
|
|
1366
2166
|
// ---- feeds
|
|
1367
2167
|
#feed(kind, cb) {
|
|
@@ -1385,99 +2185,197 @@ export class SceneHandleImpl {
|
|
|
1385
2185
|
void q.close();
|
|
1386
2186
|
return iterate(q, () => m.removeSink(kind, sink));
|
|
1387
2187
|
}
|
|
1388
|
-
|
|
2188
|
+
#ended() { return this.#closed || this.scene.mirror.state === "closed"; }
|
|
2189
|
+
/** The entities after their next change (state, not history); `not_connected` once this handle is closed. */
|
|
2190
|
+
changed() {
|
|
2191
|
+
const m = this.scene.mirror;
|
|
2192
|
+
const closed = () => closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT);
|
|
2193
|
+
if (this.#ended())
|
|
2194
|
+
return Promise.reject(closed());
|
|
2195
|
+
const from = this.get();
|
|
2196
|
+
const d = deferred();
|
|
2197
|
+
const un = this.onChange(v => {
|
|
2198
|
+
if (this.#ended())
|
|
2199
|
+
d.reject(closed());
|
|
2200
|
+
else if (v !== from)
|
|
2201
|
+
d.resolve(v);
|
|
2202
|
+
else
|
|
2203
|
+
return;
|
|
2204
|
+
un();
|
|
2205
|
+
});
|
|
2206
|
+
return d.promise;
|
|
2207
|
+
}
|
|
2208
|
+
/** The entities after each change; ends with the handle. */
|
|
2209
|
+
watch() { return watchState(cb => this.onChange(cb), () => this.get(), () => this.#ended()); }
|
|
1389
2210
|
watchLiveRemovals(cb) { return this.#feed("removals", cb); }
|
|
1390
2211
|
watchLiveDenied(cb) { return this.#feed("denied", cb); }
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
async apply(ops, base) {
|
|
2212
|
+
// ---- authored (`scene.authored`): every verb is one `apply`
|
|
2213
|
+
async #apply(ops, base) {
|
|
1394
2214
|
if (this.#closed)
|
|
1395
2215
|
throw closedError(ENDED_CLIENT);
|
|
1396
2216
|
if (this.scene.mirror.state === "detached")
|
|
1397
2217
|
throw detachedError();
|
|
1398
2218
|
await this.scene.request(reply => ({ kind: "apply", ops, base, reply }));
|
|
1399
2219
|
}
|
|
1400
|
-
async txn(build) {
|
|
2220
|
+
async #txn(build) {
|
|
1401
2221
|
const t = new Txn();
|
|
1402
2222
|
build(t);
|
|
1403
|
-
await this
|
|
2223
|
+
await this.#apply([...t.ops]);
|
|
1404
2224
|
}
|
|
1405
|
-
async spawn(kind, opts) {
|
|
2225
|
+
async #spawn(kind, opts) {
|
|
1406
2226
|
let id = "";
|
|
1407
|
-
await this
|
|
2227
|
+
await this.#txn(t => { id = t.spawn(kind, opts); });
|
|
1408
2228
|
return id;
|
|
1409
2229
|
}
|
|
1410
|
-
patch(id, set) { return this.txn(t => t.patch(id, set)); }
|
|
1411
|
-
rename(id, name) { return this.txn(t => t.rename(id, name)); }
|
|
1412
|
-
reparent(id, parent, place) { return this.txn(t => t.reparent(id, parent, place)); }
|
|
1413
|
-
despawn(id, opts) { return this.txn(t => t.despawn(id, opts)); }
|
|
1414
|
-
setLocked(id, locked) { return this.txn(t => t.setLocked(id, locked)); }
|
|
1415
|
-
// ---- live
|
|
1416
2230
|
/**
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
2231
|
+
* The entity named `name`, made to carry `components`; its view (by id). The id is the runtime's answer, never the
|
|
2232
|
+
* copy's: a `spawn` (with `parent`, a fresh id minted here) is sent first; the runtime refusing it `name_taken` names
|
|
2233
|
+
* the holder and its kind (`data.holder`, `data.kind`, `data.layer`). A holder of this kind in the authored or
|
|
2234
|
+
* calibration layer is patched with the components that differ from what the copy shows of it (all of them when the
|
|
2235
|
+
* copy does not show it yet; equal, nothing more is sent): the copy only trims the patch, it never decides. A holder
|
|
2236
|
+
* of another kind, a live one, or an answer without them: the `name_taken` is raised as it came. `parent` is for the
|
|
2237
|
+
* spawn only (an existing entity is not reparented). Any other refusal is raised as it came (`not_found` on the patch:
|
|
2238
|
+
* the entity went meanwhile; upsert again). An `id` component: `invalid_input` (`data: { component: "id" }`), nothing
|
|
2239
|
+
* sent.
|
|
1420
2240
|
*/
|
|
1421
|
-
|
|
2241
|
+
async #upsert(kind, opts) {
|
|
1422
2242
|
if (this.#closed)
|
|
1423
2243
|
throw closedError(ENDED_CLIENT);
|
|
1424
|
-
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
scene.detachedDrops++;
|
|
1428
|
-
return;
|
|
1429
|
-
}
|
|
1430
|
-
const body = {};
|
|
1431
|
-
if (batch.upsert?.length)
|
|
1432
|
-
body.upsert = batch.upsert;
|
|
1433
|
-
if (batch.poses?.length)
|
|
1434
|
-
body.poses = batch.poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
|
|
1435
|
-
if (batch.joints?.length)
|
|
1436
|
-
body.joints = batch.joints.map(r => ({ id: r.id, j: r.j }));
|
|
1437
|
-
if (batch.state?.length)
|
|
1438
|
-
body.state = batch.state.map(r => ({ id: r.id, s: r.s }));
|
|
1439
|
-
if (batch.remove?.length)
|
|
1440
|
-
body.remove = batch.remove;
|
|
1441
|
-
if (batch.lifetimeMs !== undefined)
|
|
1442
|
-
body.lifetime_ms = batch.lifetimeMs;
|
|
1443
|
-
let bytes;
|
|
2244
|
+
refuseId(opts, "scene.authored.upsert");
|
|
2245
|
+
const { name, parent: _parent, ...components } = opts;
|
|
2246
|
+
let holder;
|
|
1444
2247
|
try {
|
|
1445
|
-
|
|
2248
|
+
return this.entity(await this.#spawn(kind, opts));
|
|
1446
2249
|
}
|
|
1447
2250
|
catch (e) {
|
|
1448
|
-
|
|
2251
|
+
if (!(e instanceof SynapseError) || e.code !== "name_taken")
|
|
2252
|
+
throw e;
|
|
2253
|
+
// The runtime's answer decides: the holder, its kind and its layer.
|
|
2254
|
+
const d = (e.data ?? {});
|
|
2255
|
+
if (typeof d.holder !== "string" || d.kind !== kind || (d.layer !== "authored" && d.layer !== "calibration"))
|
|
2256
|
+
throw e;
|
|
2257
|
+
holder = d.holder;
|
|
1449
2258
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
2259
|
+
const row = this.scene.mirror.authored.get(holder);
|
|
2260
|
+
const after = row ? mergeSet(row, components) : undefined;
|
|
2261
|
+
// Only the components that differ (a patch of `flags` alone is what a locked entity accepts).
|
|
2262
|
+
const differ = Object.fromEntries(Object.entries(components).filter(([k, v]) => v !== undefined && (!row || !deepEqual(row[k], after[k]))));
|
|
2263
|
+
if (Object.keys(differ).length)
|
|
2264
|
+
await this.#txn(t => t.patch(holder, differ));
|
|
2265
|
+
return this.entity(holder);
|
|
2266
|
+
}
|
|
2267
|
+
// ---- live (`scene.live`)
|
|
2268
|
+
/**
|
|
2269
|
+
* A live entity of this node's: the call `synapse/scene/live/upsert {kind, name?, parent, lifetime_ms?, ...components}`
|
|
2270
|
+
* through the task; resolves with the view of the id the runtime answered (a name this node already claimed sends that
|
|
2271
|
+
* claim's id: its own intent). The runtime decides: its refusal is the rejection, as it came, and nothing is kept.
|
|
2272
|
+
* Granted, it is this node's claim, claimed again (same id) on every attach until `remove`. A session that goes before
|
|
2273
|
+
* the answer: asked at the next attach. Detached: `project_inactive`; the node ending: `not_connected`.
|
|
2274
|
+
*/
|
|
2275
|
+
#liveUpsert(kind, opts, live) {
|
|
2276
|
+
try {
|
|
2277
|
+
this.#liveOpen();
|
|
2278
|
+
refuseId(opts, "scene.live.upsert");
|
|
1462
2279
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
2280
|
+
catch (e) {
|
|
2281
|
+
return Promise.reject(e);
|
|
2282
|
+
}
|
|
2283
|
+
const { name, parent, ...components } = opts;
|
|
2284
|
+
let parentId;
|
|
1465
2285
|
try {
|
|
1466
|
-
|
|
2286
|
+
parentId = parent == null ? null : idOf(parent, "parent");
|
|
1467
2287
|
}
|
|
1468
2288
|
catch (e) {
|
|
1469
|
-
|
|
2289
|
+
return Promise.reject(e);
|
|
1470
2290
|
}
|
|
2291
|
+
const args = {};
|
|
2292
|
+
for (const [k, v] of Object.entries(components))
|
|
2293
|
+
if (v !== undefined)
|
|
2294
|
+
args[k] = v;
|
|
2295
|
+
args.kind = kind;
|
|
2296
|
+
if (name !== undefined)
|
|
2297
|
+
args.name = name;
|
|
2298
|
+
args.parent = parentId;
|
|
2299
|
+
if (live.lifetimeMs !== undefined)
|
|
2300
|
+
args.lifetime_ms = live.lifetimeMs;
|
|
2301
|
+
return this.scene.request(reply => ({ kind: "upsert", args, reply })).then(id => this.entity(id));
|
|
1471
2302
|
}
|
|
1472
|
-
/**
|
|
1473
|
-
|
|
2303
|
+
/**
|
|
2304
|
+
* Writes one lane batch on this node's live track (made on the first write of each session); returns at once, like
|
|
2305
|
+
* `publish`. A row goes out only for an id this node claimed in the bound incarnation; the first row of an id with no
|
|
2306
|
+
* claim asks `upsert {id}` (the override) once, and until the answer the latest row of each lane of that id is parked
|
|
2307
|
+
* (latest wins), sent once granted, dropped and counted when refused (`unclaimedDrops`; rows written while refused and
|
|
2308
|
+
* not due again are dropped and counted too). Not attached: dropped and counted (`detachedDrops`). `upsert`/`remove`
|
|
2309
|
+
* keys (the type does not allow them) are sent as given: the runtime refuses such a batch (`invalid_input`, `data {lane}`).
|
|
2310
|
+
*/
|
|
2311
|
+
#batch(batch) {
|
|
2312
|
+
this.#liveOpen();
|
|
2313
|
+
const scene = this.scene;
|
|
2314
|
+
const s = scene.node.currentSession();
|
|
2315
|
+
if (scene.mirror.state !== "attached" || !s) {
|
|
2316
|
+
scene.detachedDrops++;
|
|
2317
|
+
scene.countersChanged();
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
const now = performance.now(), ask = [];
|
|
2321
|
+
const poses = [], joints = [], state = [];
|
|
2322
|
+
let dropped = 0;
|
|
2323
|
+
const lane = (rows, out, park) => {
|
|
2324
|
+
for (const r of rows ?? []) {
|
|
2325
|
+
const g = scene.gate(r.id, now, ask);
|
|
2326
|
+
if (g === "send")
|
|
2327
|
+
out.push(r);
|
|
2328
|
+
else if (g === "park") {
|
|
2329
|
+
const p = scene.claims.get(r.id).parked;
|
|
2330
|
+
park(p, r);
|
|
2331
|
+
p.lifetimeMs = batch.lifetimeMs;
|
|
2332
|
+
}
|
|
2333
|
+
else
|
|
2334
|
+
dropped += 1;
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
lane(batch.poses, poses, (p, r) => { p.pose = r; });
|
|
2338
|
+
lane(batch.joints, joints, (p, r) => { p.joints = r; });
|
|
2339
|
+
lane(batch.state, state, (p, r) => { p.state = r; });
|
|
2340
|
+
if (dropped) {
|
|
2341
|
+
scene.unclaimedDrops += dropped;
|
|
2342
|
+
scene.countersChanged();
|
|
2343
|
+
}
|
|
2344
|
+
for (const id of ask)
|
|
2345
|
+
scene.post({ kind: "claim", id });
|
|
2346
|
+
const body = laneBody(poses, joints, state, batch.lifetimeMs) ?? {};
|
|
2347
|
+
const rest = batch;
|
|
2348
|
+
if (rest.upsert !== undefined)
|
|
2349
|
+
body.upsert = rest.upsert;
|
|
2350
|
+
if (rest.remove !== undefined)
|
|
2351
|
+
body.remove = rest.remove;
|
|
2352
|
+
if (!Object.keys(body).length)
|
|
2353
|
+
return;
|
|
2354
|
+
if (batch.lifetimeMs !== undefined)
|
|
2355
|
+
body.lifetime_ms = batch.lifetimeMs;
|
|
2356
|
+
writeLive(liveWriterOn(scene, s), encodeLive(body));
|
|
2357
|
+
}
|
|
2358
|
+
/** A live write on a closed handle, or after the node ended (the node says so at once; the mirror closes once the scene task took `nodeEnded`), is `not_connected`, never a detached drop. */
|
|
2359
|
+
#liveOpen() {
|
|
2360
|
+
if (this.#closed)
|
|
2361
|
+
throw closedError(ENDED_CLIENT);
|
|
2362
|
+
if (this.scene.node.isEnded)
|
|
2363
|
+
throw closedError(ENDED_CONNECTION_LOST);
|
|
2364
|
+
const m = this.scene.mirror;
|
|
2365
|
+
if (m.state === "closed")
|
|
2366
|
+
throw closedError(m.ended ?? ENDED_CLIENT);
|
|
2367
|
+
}
|
|
2368
|
+
/** `cb` with the entities on every change of the entities or the status (the framework bridge, React's hooks), and once more when this handle closes; returns the remover. */
|
|
2369
|
+
onChange(cb) {
|
|
1474
2370
|
if (this.#closed)
|
|
1475
2371
|
return () => { };
|
|
1476
|
-
const
|
|
2372
|
+
const l = () => cb(this.get());
|
|
2373
|
+
const un = this.scene.mirror.subscribe(l);
|
|
1477
2374
|
this.#unsubscribes.add(un);
|
|
1478
|
-
|
|
2375
|
+
this.#onClose.add(l);
|
|
2376
|
+
return () => { un(); this.#unsubscribes.delete(un); this.#onClose.delete(l); };
|
|
1479
2377
|
}
|
|
1480
|
-
/** Closes the handle: its verbs fail, its views and feeds end, its status says `closed`; the node's other scene handles go on. The last one ends the task, which
|
|
2378
|
+
/** Closes the handle: its verbs fail, its views and feeds end, its status says `closed`; the node's other scene handles go on. The last one ends the task, which releases this node's claims (one `synapse/scene/live/remove` call per claim, best effort, not awaited; the session's end releases what remains) and ends its live writer. Idempotent. */
|
|
1481
2379
|
close() {
|
|
1482
2380
|
if (this.#closed)
|
|
1483
2381
|
return;
|
|
@@ -1488,6 +2386,19 @@ export class SceneHandleImpl {
|
|
|
1488
2386
|
for (const un of this.#unsubscribes)
|
|
1489
2387
|
un();
|
|
1490
2388
|
this.#unsubscribes.clear();
|
|
2389
|
+
for (const d of this.#readies)
|
|
2390
|
+
d.reject(closedError(ENDED_CLIENT));
|
|
2391
|
+
this.#readies.clear();
|
|
2392
|
+
for (const l of [...this.#onClose]) {
|
|
2393
|
+
try {
|
|
2394
|
+
l();
|
|
2395
|
+
}
|
|
2396
|
+
catch (e) {
|
|
2397
|
+
rethrowLater(e);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
this.#onClose.clear();
|
|
2401
|
+
this.#status.dispose();
|
|
1491
2402
|
if (--this.scene.handles === 0)
|
|
1492
2403
|
this.scene.post({ kind: "close" });
|
|
1493
2404
|
}
|
|
@@ -1495,12 +2406,12 @@ export class SceneHandleImpl {
|
|
|
1495
2406
|
}
|
|
1496
2407
|
/**
|
|
1497
2408
|
* The active project's scene (one per project; one task per node, shared by every handle). Resolves
|
|
1498
|
-
* once the handle is attached
|
|
1499
|
-
*
|
|
2409
|
+
* once the handle is attached (the copy at the version `synapse/scene/open` answered, or past it), or at
|
|
2410
|
+
* once `detached` when the runtime says no project is active; the
|
|
2411
|
+
* handle then follows the incarnations as they come and go. Without a session (before the first, between
|
|
2412
|
+
* two) the task waits for the next one, like after a drop.
|
|
1500
2413
|
*/
|
|
1501
2414
|
export async function openScene(node, registry, warn) {
|
|
1502
|
-
if (!node.currentSession())
|
|
1503
|
-
throw new SynapseError("not_connected");
|
|
1504
2415
|
let scene;
|
|
1505
2416
|
let h;
|
|
1506
2417
|
for (;;) {
|