@synapse-sdk/client 0.3.3 → 0.3.4
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 +7 -1
- package/dist/calls.js +19 -0
- package/dist/node.d.ts +62 -24
- package/dist/node.js +205 -66
- package/dist/scene.d.ts +150 -56
- package/dist/scene.js +995 -163
- 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 +4 -2
- package/dist/store.d.ts +22 -11
- package/dist/store.js +112 -26
- package/dist/synapse.d.ts +72 -45
- package/dist/synapse.js +179 -112
- 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 +268 -97
- 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
|
@@ -21,13 +21,25 @@
|
|
|
21
21
|
// - `apply` / `applyDone`: an authoring batch (it names the bound incarnation's epoch) and the
|
|
22
22
|
// runtime's answer; the caller is answered once the mirror shows the version;
|
|
23
23
|
// - `notice`: the runtime dropped some of this node's live rows (`not_owner` with `data.ids`);
|
|
24
|
+
// - `upsert` / `remove` / `claim` / `claimDone` / `reclaim`: this node's live claims (PLAN v0.31: decisions are calls,
|
|
25
|
+
// data is streams): `live.upsert` and `live.remove` are the calls `synapse/scene/live/upsert` and
|
|
26
|
+
// `synapse/scene/live/remove`; a lane write for an id this node has not claimed in the bound incarnation asks the
|
|
27
|
+
// claim once (the override of an entity of the scene); the runtime's answers; a refused live entity due again;
|
|
24
28
|
// - `close`: the last open handle left.
|
|
25
29
|
//
|
|
30
|
+
// The claims are this node's own intent (id → the claim's args), never world state: on every attach to an incarnation
|
|
31
|
+
// (a new session, a project switch, a restarted scene owner) the live entities among them are claimed again with the
|
|
32
|
+
// same ids; an override is claimed again by its next lane write. A refused re-claim is asked again, paced, with a
|
|
33
|
+
// warning (the store's re-open rule and constants: `refusalPaceMs`). Lane rows for an id not claimed in the bound
|
|
34
|
+
// incarnation are parked (latest per lane) while the claim is asked, sent once granted, dropped and counted
|
|
35
|
+
// (`status.get().unclaimedDrops`) when refused. Nothing here reads the mirror to decide what to send: the mirror is a
|
|
36
|
+
// read cache. The Rust client's `scene.rs` is the reference; this file behaves as it does.
|
|
37
|
+
//
|
|
26
38
|
// States: `attached` (an incarnation's snapshot is in the mirror, and every frame of both tracks this
|
|
27
39
|
// node already held when the first frame of each arrived has been applied), `detached` (none: no
|
|
28
40
|
// 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`.
|
|
41
|
+
// detached the entities are empty, every author verb fails `project_inactive` at once and a live
|
|
42
|
+
// write drops its batch and counts it. A closed handle's verbs fail `not_connected`.
|
|
31
43
|
//
|
|
32
44
|
// Known behaviour (as the Rust client's):
|
|
33
45
|
// - live rows that disappear only through a keyframe (the ticks that removed them were missed) leave
|
|
@@ -42,9 +54,9 @@
|
|
|
42
54
|
import * as Moq from "@moq/net";
|
|
43
55
|
import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs } from "@synapse-sdk/core";
|
|
44
56
|
import { startOn } from "./calls.js";
|
|
45
|
-
import { REFOLLOW_PACE_MS } from "./channel.js";
|
|
57
|
+
import { REFOLLOW_PACE_MS, isTransient, refusalPaceMs } from "./channel.js";
|
|
46
58
|
import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
|
|
47
|
-
import { AsyncQueue, deferred, replaceEqualDeep } from "./util.js";
|
|
59
|
+
import { AsyncQueue, StatusCell, deferred, replaceEqualDeep, rethrowLater, watchState } from "./util.js";
|
|
48
60
|
/** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
|
|
49
61
|
const SCENE_BASE = "runtime/scene";
|
|
50
62
|
const AUTHORED_TRACK = "authored";
|
|
@@ -60,12 +72,22 @@ export const PATCH_PROTECTED = ["id", "name", "parent", "order", "layer", "meta"
|
|
|
60
72
|
/** moq's cap on one frame. */
|
|
61
73
|
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
62
74
|
const EMPTY = Object.freeze({});
|
|
75
|
+
/** What a closed handle's `tree()` and `query()` read: one object each, so a reader that compares by reference stays quiet. */
|
|
76
|
+
const EMPTY_TREE = Object.freeze([]);
|
|
77
|
+
const EMPTY_LIST = Object.freeze([]);
|
|
63
78
|
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
|
|
64
79
|
/** Deep equality of decoded values (structural sharing keeps `a` when equal). */
|
|
65
80
|
const deepEqual = (a, b) => replaceEqualDeep(a, b) === a;
|
|
66
81
|
const parentOf = (e) => (typeof e.parent === "string" ? e.parent : null);
|
|
67
82
|
const orderOf = (e) => (typeof e.order === "string" ? e.order : undefined);
|
|
68
|
-
|
|
83
|
+
/** The id `x` names: a string, or a view this SDK made (`EntityView`); anything else (`{ id }` included) is `invalid_input` naming `arg`. */
|
|
84
|
+
const idOf = (x, arg = "entity") => {
|
|
85
|
+
if (typeof x === "string")
|
|
86
|
+
return x;
|
|
87
|
+
if (x instanceof EntityView)
|
|
88
|
+
return x.id;
|
|
89
|
+
throw new SynapseError("invalid_input", `scene: ${arg} is an id or an entity view (scene.entity, upsert), not ${x === null ? "null" : typeof x}`);
|
|
90
|
+
};
|
|
69
91
|
// ------------------------------------------------------------------------------------- helpers
|
|
70
92
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
71
93
|
const RAND_BITS = 74n;
|
|
@@ -227,17 +249,18 @@ export class Txn {
|
|
|
227
249
|
#spawnedUnder(parent) {
|
|
228
250
|
return this.#ops.flatMap(op => (op.op === "spawn" && op.entity.parent === parent ? [op.entity.id] : []));
|
|
229
251
|
}
|
|
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. */
|
|
252
|
+
/** 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
253
|
spawn(kind, opts) {
|
|
232
254
|
const { parent, id: given, ...components } = opts;
|
|
233
255
|
const id = given ?? ulid();
|
|
234
|
-
const entity = { ...components, id, kind, parent: parent
|
|
256
|
+
const entity = { ...components, id, kind, parent: parent == null ? null : idOf(parent, "parent") };
|
|
235
257
|
entity.layer ??= "authored";
|
|
236
258
|
this.#ops.push({ op: "spawn", entity });
|
|
237
259
|
return id;
|
|
238
260
|
}
|
|
239
|
-
/** Sets components of
|
|
240
|
-
patch(
|
|
261
|
+
/** Sets components of the entity (one level deep: an object merges into an object). */
|
|
262
|
+
patch(entity, set) {
|
|
263
|
+
const id = idOf(entity);
|
|
241
264
|
// A protected key is the runtime's to refuse: such a patch is sent as it is.
|
|
242
265
|
const protectedKey = Object.keys(set).some(k => PATCH_PROTECTED.includes(k));
|
|
243
266
|
const i = protectedKey ? -1 : this.#spawnIndex(id);
|
|
@@ -248,7 +271,8 @@ export class Txn {
|
|
|
248
271
|
}
|
|
249
272
|
this.#ops.push({ op: "patch", id, set });
|
|
250
273
|
}
|
|
251
|
-
rename(
|
|
274
|
+
rename(entity, name) {
|
|
275
|
+
const id = idOf(entity);
|
|
252
276
|
const i = this.#spawnIndex(id);
|
|
253
277
|
if (i >= 0) {
|
|
254
278
|
const op = this.#ops[i];
|
|
@@ -257,17 +281,18 @@ export class Txn {
|
|
|
257
281
|
}
|
|
258
282
|
this.#ops.push({ op: "rename", id, name });
|
|
259
283
|
}
|
|
260
|
-
/** Moves
|
|
261
|
-
reparent(
|
|
262
|
-
const op = { op: "reparent", id, parent };
|
|
284
|
+
/** Moves the entity under `parent` (`null`: the world root), before or after a sibling (default: after the last). */
|
|
285
|
+
reparent(entity, parent, place) {
|
|
286
|
+
const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
|
|
263
287
|
if (place && "before" in place)
|
|
264
|
-
op.before = place.before;
|
|
288
|
+
op.before = idOf(place.before, "before");
|
|
265
289
|
else if (place && "after" in place)
|
|
266
|
-
op.after = place.after;
|
|
290
|
+
op.after = idOf(place.after, "after");
|
|
267
291
|
this.#ops.push(op);
|
|
268
292
|
}
|
|
269
|
-
/** Despawns
|
|
270
|
-
despawn(
|
|
293
|
+
/** 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). */
|
|
294
|
+
despawn(entity, opts = {}) {
|
|
295
|
+
const id = idOf(entity);
|
|
271
296
|
const cascade = opts.cascade ?? false;
|
|
272
297
|
if (this.#spawnIndex(id) >= 0 && (cascade || this.#spawnedUnder(id).length === 0)) {
|
|
273
298
|
this.#cancel(id);
|
|
@@ -275,8 +300,8 @@ export class Txn {
|
|
|
275
300
|
}
|
|
276
301
|
this.#ops.push({ op: "despawn", id, cascade });
|
|
277
302
|
}
|
|
278
|
-
/** Locks or unlocks
|
|
279
|
-
setLocked(
|
|
303
|
+
/** Locks or unlocks the entity (a flags-only patch, which a lock lets through). */
|
|
304
|
+
setLocked(entity, locked) { this.patch(entity, { flags: { locked } }); }
|
|
280
305
|
/** Drops this batch's spawn of `id`, every op naming it, and the spawns under it. */
|
|
281
306
|
#cancel(id) {
|
|
282
307
|
const children = this.#spawnedUnder(id);
|
|
@@ -313,7 +338,8 @@ export function foldAuthored(authored, op, t, by) {
|
|
|
313
338
|
const e = authored.get(op.id);
|
|
314
339
|
if (!e)
|
|
315
340
|
return [];
|
|
316
|
-
|
|
341
|
+
// An unchanged component keeps its object, so a view of the authored entity stays quiet on it.
|
|
342
|
+
authored.set(op.id, replaceEqualDeep(e, stamped(mergeSet(e, op.set), op.rev)));
|
|
317
343
|
return [op.id];
|
|
318
344
|
}
|
|
319
345
|
case "reparent": {
|
|
@@ -336,34 +362,6 @@ export function foldAuthored(authored, op, t, by) {
|
|
|
336
362
|
}
|
|
337
363
|
return [];
|
|
338
364
|
}
|
|
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
365
|
const list = (v) => (Array.isArray(v) ? v : []);
|
|
368
366
|
/** 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
367
|
function putLanes(table, f) {
|
|
@@ -393,6 +391,20 @@ export function liveTableOf(k) {
|
|
|
393
391
|
putLanes(t, k);
|
|
394
392
|
return t;
|
|
395
393
|
}
|
|
394
|
+
/** The authored or calibration entity named `name` (live overrides never count). */
|
|
395
|
+
export function authoredNamed(authored, name) {
|
|
396
|
+
for (const e of authored.values())
|
|
397
|
+
if (e.name === name)
|
|
398
|
+
return e;
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/** The id of the live entity named `name` (a lane-only row carries no name). */
|
|
402
|
+
export function liveNamed(live, name) {
|
|
403
|
+
for (const [id, r] of live)
|
|
404
|
+
if (r.entity?.name === name)
|
|
405
|
+
return id;
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
396
408
|
/** 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
409
|
export function foldLive(table, d) {
|
|
398
410
|
const ids = [];
|
|
@@ -471,7 +483,7 @@ export class Mirror {
|
|
|
471
483
|
#tree = [];
|
|
472
484
|
#treeStale = false;
|
|
473
485
|
#listeners = new Set();
|
|
474
|
-
#sinks = {
|
|
486
|
+
#sinks = { removals: new Set(), denied: new Set(), states: new Set() };
|
|
475
487
|
subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
|
|
476
488
|
#notify() { for (const l of [...this.#listeners]) {
|
|
477
489
|
try {
|
|
@@ -508,8 +520,6 @@ export class Mirror {
|
|
|
508
520
|
this.state = s;
|
|
509
521
|
this.#send("states", s);
|
|
510
522
|
}
|
|
511
|
-
#emitChanges(ops) { if (ops.length)
|
|
512
|
-
this.#send("changes", ops); }
|
|
513
523
|
/** The merged reads of `ids` recomputed. */
|
|
514
524
|
#refresh(ids) {
|
|
515
525
|
let merged;
|
|
@@ -543,20 +553,18 @@ export class Mirror {
|
|
|
543
553
|
this.merged = replaceEqualDeep(this.merged, merged);
|
|
544
554
|
this.#treeStale = true;
|
|
545
555
|
}
|
|
546
|
-
/** A snapshot of incarnation `epoch` replaces the authored copy
|
|
556
|
+
/** A snapshot of incarnation `epoch` replaces the authored copy. Not yet `attached`: the reader catches up first (`attach`). */
|
|
547
557
|
applySnapshot(epoch, s) {
|
|
548
558
|
if (this.state === "closed")
|
|
549
559
|
return;
|
|
550
560
|
// An entity equal to the one held keeps its object, so a view of the authored entity stays quiet.
|
|
551
561
|
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
562
|
this.authored = next;
|
|
554
563
|
this.version = s.version;
|
|
555
564
|
this.hash = s.hash;
|
|
556
565
|
this.epoch = epoch;
|
|
557
566
|
this.gap = false;
|
|
558
567
|
this.#rebuild();
|
|
559
|
-
this.#emitChanges(ops);
|
|
560
568
|
this.#notify();
|
|
561
569
|
}
|
|
562
570
|
/** The reader has applied everything this node held when it bound the incarnation. */
|
|
@@ -564,7 +572,7 @@ export class Mirror {
|
|
|
564
572
|
this.#setState("attached");
|
|
565
573
|
this.#notify();
|
|
566
574
|
} }
|
|
567
|
-
/** One applied batch: its ops folded into the authored copy
|
|
575
|
+
/** One applied batch: its ops folded into the authored copy. */
|
|
568
576
|
applyDiff(d, warn) {
|
|
569
577
|
if (this.epoch === undefined || this.state === "closed")
|
|
570
578
|
return;
|
|
@@ -578,7 +586,6 @@ export class Mirror {
|
|
|
578
586
|
this.version = d.version;
|
|
579
587
|
this.hash = d.hash;
|
|
580
588
|
this.#refresh(touched);
|
|
581
|
-
this.#emitChanges(d.ops);
|
|
582
589
|
this.#notify();
|
|
583
590
|
}
|
|
584
591
|
/** A keyframe replaces the live table. */
|
|
@@ -610,11 +617,10 @@ export class Mirror {
|
|
|
610
617
|
denied(ids) { if (this.state !== "closed")
|
|
611
618
|
this.#send("denied", ids); }
|
|
612
619
|
setPending(n) { this.pending = n; this.#notify(); }
|
|
613
|
-
/** No incarnation: the copy empties
|
|
620
|
+
/** No incarnation: the copy empties, `detached`. */
|
|
614
621
|
detach() {
|
|
615
622
|
if (this.state === "closed")
|
|
616
623
|
return;
|
|
617
|
-
const ops = netDifference(this.authored, new Map());
|
|
618
624
|
this.authored = new Map();
|
|
619
625
|
this.live = new Map();
|
|
620
626
|
this.merged = EMPTY;
|
|
@@ -624,7 +630,6 @@ export class Mirror {
|
|
|
624
630
|
this.hash = "";
|
|
625
631
|
this.gap = false;
|
|
626
632
|
this.#setState("detached");
|
|
627
|
-
this.#emitChanges(ops);
|
|
628
633
|
this.#notify();
|
|
629
634
|
}
|
|
630
635
|
/** The end: emptied, `closed` (`reason` recorded), every feed ended. */
|
|
@@ -645,7 +650,7 @@ export class Mirror {
|
|
|
645
650
|
// --------------------------------------------------------------------------------------- views
|
|
646
651
|
/**
|
|
647
652
|
* 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 (`
|
|
653
|
+
* changes). It follows the mirror only while someone listens (`onChange`, a pending `changed()`, a `watch()` loop),
|
|
649
654
|
* and while it does it is in `active`, so the handle's close can end it.
|
|
650
655
|
*/
|
|
651
656
|
class MirrorView {
|
|
@@ -654,9 +659,12 @@ class MirrorView {
|
|
|
654
659
|
active;
|
|
655
660
|
ended;
|
|
656
661
|
#listeners = new Set();
|
|
662
|
+
/** Pending `changed()` calls, each with the value it was armed on: it resolves on the first value that differs from it. */
|
|
657
663
|
#waiting = [];
|
|
658
664
|
#unmirror;
|
|
659
665
|
#last;
|
|
666
|
+
/** `end()` ran (it runs once). */
|
|
667
|
+
#over = false;
|
|
660
668
|
constructor(mirror, read, active, ended) {
|
|
661
669
|
this.mirror = mirror;
|
|
662
670
|
this.read = read;
|
|
@@ -664,18 +672,21 @@ class MirrorView {
|
|
|
664
672
|
this.ended = ended;
|
|
665
673
|
}
|
|
666
674
|
get() { return this.read(); }
|
|
667
|
-
|
|
675
|
+
/** `cb` with the value on each change, and once more when the view ends; returns the remover. */
|
|
676
|
+
onChange(cb) {
|
|
668
677
|
if (this.ended())
|
|
669
678
|
return () => { };
|
|
670
|
-
this.#listeners.add(
|
|
679
|
+
this.#listeners.add(cb);
|
|
671
680
|
this.#follow();
|
|
672
|
-
return () => { this.#listeners.delete(
|
|
681
|
+
return () => { this.#listeners.delete(cb); this.#unfollow(); };
|
|
673
682
|
}
|
|
683
|
+
/** The value after each change; ends with the handle. */
|
|
684
|
+
watch() { return watchState(cb => this.onChange(cb), () => this.get(), this.ended); }
|
|
674
685
|
changed() {
|
|
675
686
|
if (this.ended())
|
|
676
687
|
return Promise.resolve(this.read());
|
|
677
688
|
const d = deferred();
|
|
678
|
-
this.#waiting.push(d);
|
|
689
|
+
this.#waiting.push({ d, armedOn: this.read() });
|
|
679
690
|
this.#follow();
|
|
680
691
|
return d.promise;
|
|
681
692
|
}
|
|
@@ -694,6 +705,11 @@ class MirrorView {
|
|
|
694
705
|
this.active.delete(this);
|
|
695
706
|
}
|
|
696
707
|
#update() {
|
|
708
|
+
// The node ended (the mirror closed): the view ends with it.
|
|
709
|
+
if (this.ended()) {
|
|
710
|
+
this.end();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
697
713
|
let v;
|
|
698
714
|
try {
|
|
699
715
|
v = this.read();
|
|
@@ -705,20 +721,28 @@ class MirrorView {
|
|
|
705
721
|
if (v === this.#last)
|
|
706
722
|
return;
|
|
707
723
|
this.#last = v;
|
|
724
|
+
// The waits this change answers are taken before the listeners run: a `changed()` a listener arms waits for the next change.
|
|
725
|
+
// 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.
|
|
726
|
+
const waiting = this.#waiting.splice(0);
|
|
727
|
+
this.#waiting.push(...waiting.filter(w => w.armedOn === v));
|
|
708
728
|
for (const l of [...this.#listeners]) {
|
|
709
729
|
try {
|
|
710
|
-
l();
|
|
730
|
+
l(v);
|
|
711
731
|
}
|
|
712
732
|
catch (e) {
|
|
713
733
|
rethrowLater(e);
|
|
714
734
|
}
|
|
715
735
|
}
|
|
716
|
-
for (const
|
|
717
|
-
|
|
736
|
+
for (const w of waiting)
|
|
737
|
+
if (w.armedOn !== v)
|
|
738
|
+
w.d.resolve(v);
|
|
718
739
|
this.#unfollow();
|
|
719
740
|
}
|
|
720
|
-
/** The handle closed: a pending `changed()` resolves with the value now;
|
|
741
|
+
/** 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
742
|
end() {
|
|
743
|
+
if (this.#over)
|
|
744
|
+
return;
|
|
745
|
+
this.#over = true;
|
|
722
746
|
let v;
|
|
723
747
|
try {
|
|
724
748
|
v = this.read();
|
|
@@ -726,12 +750,73 @@ class MirrorView {
|
|
|
726
750
|
catch {
|
|
727
751
|
v = this.#last;
|
|
728
752
|
}
|
|
729
|
-
for (const
|
|
730
|
-
d.resolve(v);
|
|
753
|
+
for (const w of this.#waiting.splice(0))
|
|
754
|
+
w.d.resolve(v);
|
|
755
|
+
for (const l of [...this.#listeners]) {
|
|
756
|
+
try {
|
|
757
|
+
l(v);
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
rethrowLater(e);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
731
763
|
this.#listeners.clear();
|
|
732
764
|
this.#unfollow();
|
|
733
765
|
}
|
|
734
766
|
}
|
|
767
|
+
/** `e`, or its `component` (`undefined` when `e` is). */
|
|
768
|
+
const pick = (e, component) => component === undefined ? e : e === undefined ? undefined : e[component];
|
|
769
|
+
/**
|
|
770
|
+
* One entity as a view (`scene.entity(id | { name })`): the `SceneView` of the merged entity with the store handle's
|
|
771
|
+
* members, and the entity's two planes (`authored`, `live`), frozen objects made once with the view. `read` picks the
|
|
772
|
+
* merged entity for the id the view names now; `ops.write` patches the authored entity the view names at the call,
|
|
773
|
+
* `ops.live` writes its live lanes, `ops.remove` drops its live row.
|
|
774
|
+
*/
|
|
775
|
+
class EntityView extends MirrorView {
|
|
776
|
+
ops;
|
|
777
|
+
authored;
|
|
778
|
+
live;
|
|
779
|
+
constructor(mirror, read, active, ended, ops) {
|
|
780
|
+
super(mirror, read, active, ended);
|
|
781
|
+
this.ops = ops;
|
|
782
|
+
this.authored = Object.freeze({
|
|
783
|
+
get: ((component) => pick(ops.readAuthored(), component)),
|
|
784
|
+
set: ((a, b) => ops.write(typeof a === "string" ? { [a]: b } : a)),
|
|
785
|
+
});
|
|
786
|
+
this.live = Object.freeze({
|
|
787
|
+
get: ((component) => pick(ops.readLive(), component)),
|
|
788
|
+
set: (components, opts = {}) => ops.live(components, opts),
|
|
789
|
+
remove: () => ops.remove(),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
get id() { return this.ops.id(); }
|
|
793
|
+
get(component) { return pick(this.read(), component); }
|
|
794
|
+
/**
|
|
795
|
+
* The entity (or `component` of it) after each change; changes in between fold into the latest. A component is
|
|
796
|
+
* compared by reference: the mirror keeps an unchanged component's object across a change elsewhere (structural
|
|
797
|
+
* sharing), so only a change of that component wakes it.
|
|
798
|
+
*/
|
|
799
|
+
watch(component) {
|
|
800
|
+
return watchState(cb => this.onChange(cb), () => this.get(component), this.ended);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** 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. */
|
|
804
|
+
function release(node, session, id) {
|
|
805
|
+
if (!session)
|
|
806
|
+
return;
|
|
807
|
+
try {
|
|
808
|
+
void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
|
|
809
|
+
}
|
|
810
|
+
catch { /* the session's end releases it */ }
|
|
811
|
+
}
|
|
812
|
+
/** The id a claim's reply names. */
|
|
813
|
+
function replyId(v) {
|
|
814
|
+
const id = v?.id;
|
|
815
|
+
if (typeof id !== "string")
|
|
816
|
+
throw new SynapseError("internal", "scene live claim reply not understood");
|
|
817
|
+
return id;
|
|
818
|
+
}
|
|
819
|
+
const sessionGone = (e) => e.code === "connection_lost" || e.code === "not_connected";
|
|
735
820
|
function openLiveWriter(s) {
|
|
736
821
|
const bc = s.createBroadcast(`${s.prefix}/scene/live`);
|
|
737
822
|
// Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
|
|
@@ -748,6 +833,82 @@ function closeLiveWriter(w) {
|
|
|
748
833
|
}
|
|
749
834
|
catch { /* closed */ }
|
|
750
835
|
}
|
|
836
|
+
/** A live batch's wire bytes. */
|
|
837
|
+
function encodeLive(body) {
|
|
838
|
+
let bytes;
|
|
839
|
+
try {
|
|
840
|
+
bytes = encodeBody(body);
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
|
|
844
|
+
}
|
|
845
|
+
if (bytes.length > MAX_FRAME_BYTES)
|
|
846
|
+
throw frameTooLarge("scene live batch", bytes.length);
|
|
847
|
+
return bytes;
|
|
848
|
+
}
|
|
849
|
+
/** One frame on the live writer. */
|
|
850
|
+
function writeLive(w, bytes) {
|
|
851
|
+
const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
|
|
852
|
+
w.seq = (w.seq + 1) >>> 0;
|
|
853
|
+
try {
|
|
854
|
+
w.stream.write(header, bytes);
|
|
855
|
+
}
|
|
856
|
+
catch (e) {
|
|
857
|
+
throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** Session `s`'s live writer: the current one, or a new one when that is another session's. */
|
|
861
|
+
function liveWriterOn(scene, s) {
|
|
862
|
+
let w = scene.writer;
|
|
863
|
+
if (w && w.session === s)
|
|
864
|
+
return w;
|
|
865
|
+
closeLiveWriter(w);
|
|
866
|
+
try {
|
|
867
|
+
w = scene.writer = openLiveWriter(s);
|
|
868
|
+
}
|
|
869
|
+
catch (e) {
|
|
870
|
+
scene.writer = undefined;
|
|
871
|
+
throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
|
|
872
|
+
}
|
|
873
|
+
return w;
|
|
874
|
+
}
|
|
875
|
+
const UNCLAIMED = { kind: "unclaimed" }, ASKING = { kind: "asking" }, CLAIMED = { kind: "claimed" };
|
|
876
|
+
const parkedRows = (p) => (p.pose ? 1 : 0) + (p.joints ? 1 : 0) + (p.state ? 1 : 0);
|
|
877
|
+
/** One of this node's claims: its own intent, never world state. */
|
|
878
|
+
class Claim {
|
|
879
|
+
args;
|
|
880
|
+
state;
|
|
881
|
+
/** Refusals of it in a row (the pace of the next ask). */
|
|
882
|
+
refusals = 0;
|
|
883
|
+
parked = {};
|
|
884
|
+
/** `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. */
|
|
885
|
+
constructor(args, state) {
|
|
886
|
+
this.args = args;
|
|
887
|
+
this.state = state;
|
|
888
|
+
}
|
|
889
|
+
/** A live entity (claimed again on every attach), not an override (claimed by its lanes). */
|
|
890
|
+
get isEntity() { return Object.keys(this.args).length > 0; }
|
|
891
|
+
}
|
|
892
|
+
/** 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. */
|
|
893
|
+
function timerDue(c) {
|
|
894
|
+
const st = c.state;
|
|
895
|
+
return st.kind === "paced" && (c.isEntity || (!st.refused && parkedRows(c.parked) > 0)) ? st.at : undefined;
|
|
896
|
+
}
|
|
897
|
+
/** A lane batch on the wire: data only (stamps are the runtime's). */
|
|
898
|
+
function laneBody(poses, joints, state, lifetimeMs) {
|
|
899
|
+
const body = {};
|
|
900
|
+
if (poses.length)
|
|
901
|
+
body.poses = poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
|
|
902
|
+
if (joints.length)
|
|
903
|
+
body.joints = joints.map(r => ({ id: r.id, j: r.j }));
|
|
904
|
+
if (state.length)
|
|
905
|
+
body.state = state.map(r => ({ id: r.id, s: r.s }));
|
|
906
|
+
if (!Object.keys(body).length)
|
|
907
|
+
return undefined;
|
|
908
|
+
if (lifetimeMs !== undefined)
|
|
909
|
+
body.lifetime_ms = lifetimeMs;
|
|
910
|
+
return body;
|
|
911
|
+
}
|
|
751
912
|
/** The node's scene: what every handle shares with the task. */
|
|
752
913
|
export class Scene {
|
|
753
914
|
node;
|
|
@@ -758,14 +919,52 @@ export class Scene {
|
|
|
758
919
|
/** Handles neither closed nor dropped; the one that brings it to 0 tells the task `close`. */
|
|
759
920
|
handles = 0;
|
|
760
921
|
nextHandle = 0;
|
|
761
|
-
/** The live writer of the current session, made on the first
|
|
922
|
+
/** The live writer of the current session, made on the first live write. */
|
|
762
923
|
writer;
|
|
924
|
+
/** This node's claims by id (what `live.upsert` made and what lane writes asked for); `live.remove` drops one. Nothing else is kept. */
|
|
925
|
+
claims = new Map();
|
|
763
926
|
detachedDrops = 0;
|
|
927
|
+
/** Lane rows dropped because their id's claim was refused (and not due again), or parked rows a refusal dropped. */
|
|
928
|
+
unclaimedDrops = 0;
|
|
929
|
+
/** Told when a counter of the status changes (the listeners of the status state values). */
|
|
930
|
+
counters = new Set();
|
|
764
931
|
constructor(node, warn) {
|
|
765
932
|
this.node = node;
|
|
766
933
|
this.warn = warn;
|
|
767
934
|
}
|
|
768
935
|
post(e) { return this.mailbox.push(e); }
|
|
936
|
+
countersChanged() { for (const l of [...this.counters]) {
|
|
937
|
+
try {
|
|
938
|
+
l();
|
|
939
|
+
}
|
|
940
|
+
catch (e) {
|
|
941
|
+
rethrowLater(e);
|
|
942
|
+
}
|
|
943
|
+
} }
|
|
944
|
+
/**
|
|
945
|
+
* What happens to a lane row of `id` now. An id with no claim becomes the override's claim; an override that is
|
|
946
|
+
* unclaimed, or refused and due again, is marked asked and returned in `ask` (the task asks it: one ask per id at a
|
|
947
|
+
* time). A live entity's claim is asked by the task (on attach, paced after a refusal), never by a lane.
|
|
948
|
+
*/
|
|
949
|
+
gate(id, now, ask) {
|
|
950
|
+
let c = this.claims.get(id);
|
|
951
|
+
if (!c)
|
|
952
|
+
this.claims.set(id, (c = new Claim({}, UNCLAIMED)));
|
|
953
|
+
const st = c.state;
|
|
954
|
+
if (st.kind === "claimed")
|
|
955
|
+
return "send";
|
|
956
|
+
if (st.kind === "asking")
|
|
957
|
+
return "park";
|
|
958
|
+
if (st.kind === "unclaimed" && c.isEntity)
|
|
959
|
+
return "park";
|
|
960
|
+
if (st.kind === "paced" && !st.refused)
|
|
961
|
+
return "park";
|
|
962
|
+
if (st.kind === "paced" && (c.isEntity || st.at > now))
|
|
963
|
+
return "drop";
|
|
964
|
+
c.state = ASKING;
|
|
965
|
+
ask.push(id);
|
|
966
|
+
return "park";
|
|
967
|
+
}
|
|
769
968
|
request(make) {
|
|
770
969
|
const reply = deferred();
|
|
771
970
|
if (!this.post(make(reply)))
|
|
@@ -778,6 +977,16 @@ const detachedError = () => new SynapseError("project_inactive", "the scene is d
|
|
|
778
977
|
function closedError(reason) {
|
|
779
978
|
return new SynapseError("not_connected", reason === ENDED_CONNECTION_LOST ? "the node ended" : "the scene handle is closed");
|
|
780
979
|
}
|
|
980
|
+
/** A view by name whose name no entity holds: nothing to send to (`not_found`, as the runtime answers an unknown id). */
|
|
981
|
+
function notNamed(ref) {
|
|
982
|
+
const name = typeof ref === "string" ? undefined : ref.name;
|
|
983
|
+
return new SynapseError("not_found", { message: `scene: no entity is named ${JSON.stringify(name)}`, data: { name } });
|
|
984
|
+
}
|
|
985
|
+
/** An `upsert` is keyed by its name: an `id` component (the type forbids it; JavaScript does not) is `invalid_input`, nothing sent. */
|
|
986
|
+
function refuseId(opts, verb) {
|
|
987
|
+
if ("id" in opts)
|
|
988
|
+
throw new SynapseError("invalid_input", { message: `${verb} is keyed by its name: an id component is not taken`, data: { component: "id" } });
|
|
989
|
+
}
|
|
781
990
|
const asError = (e) => (e instanceof SynapseError ? e : new SynapseError("internal", String(e)));
|
|
782
991
|
export function startScene(scene, registry) { void run(scene, registry); }
|
|
783
992
|
async function run(scene, registry) {
|
|
@@ -793,6 +1002,23 @@ async function run(scene, registry) {
|
|
|
793
1002
|
let unwatchProject;
|
|
794
1003
|
const joining = [];
|
|
795
1004
|
let waiters = [];
|
|
1005
|
+
/** The attach the claims are made for (session and incarnation), if any. */
|
|
1006
|
+
let bound;
|
|
1007
|
+
/** Counts attaches: an answer to a claim asked in an earlier one is not this one's. */
|
|
1008
|
+
let bindNo = 0;
|
|
1009
|
+
/** The ids whose claim is being asked again (one ask per id at a time). */
|
|
1010
|
+
const asking = new Set();
|
|
1011
|
+
/** `live.upsert` calls waiting for an attach (attached a moment before the task was told, or their session went before the answer). */
|
|
1012
|
+
let parkedUpserts = [];
|
|
1013
|
+
/**
|
|
1014
|
+
* The `live.upsert` calls on the wire, by token: the id each named (a claim of that name this node held), and whether its
|
|
1015
|
+
* 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
|
|
1016
|
+
* released, never kept).
|
|
1017
|
+
*/
|
|
1018
|
+
const firsts = new Map();
|
|
1019
|
+
let nextFirst = 0;
|
|
1020
|
+
let reclaimTimer;
|
|
1021
|
+
let reclaimAt = Infinity;
|
|
796
1022
|
const inactive = () => project !== undefined && project.active === null;
|
|
797
1023
|
const answerJoins = () => { for (const j of joining.splice(0))
|
|
798
1024
|
j.resolve(); };
|
|
@@ -822,13 +1048,14 @@ async function run(scene, registry) {
|
|
|
822
1048
|
const attempt = attempts;
|
|
823
1049
|
const stop = new AbortController();
|
|
824
1050
|
phase = { kind: "following", attempt, stop };
|
|
825
|
-
void followIncarnation(scene, s, announced, endedEpoch, stop.signal).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
|
|
1051
|
+
void followIncarnation(scene, s, announced, endedEpoch, stop.signal, attempt).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
|
|
826
1052
|
};
|
|
827
1053
|
/** The incarnation is let go: the mirror empties, writes waiting to show fail, followed again after `paceMs`. */
|
|
828
1054
|
const again = (paceMs) => {
|
|
829
1055
|
failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
|
|
830
1056
|
stopPhase();
|
|
831
1057
|
mirror.detach();
|
|
1058
|
+
unbind();
|
|
832
1059
|
const a = ++attempts;
|
|
833
1060
|
phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
|
|
834
1061
|
};
|
|
@@ -838,6 +1065,7 @@ async function run(scene, registry) {
|
|
|
838
1065
|
stopPhase();
|
|
839
1066
|
failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
|
|
840
1067
|
mirror.detach();
|
|
1068
|
+
unbind();
|
|
841
1069
|
closeLiveWriter(scene.writer);
|
|
842
1070
|
scene.writer = undefined;
|
|
843
1071
|
unwatchProject?.();
|
|
@@ -849,13 +1077,317 @@ async function run(scene, registry) {
|
|
|
849
1077
|
answerJoins();
|
|
850
1078
|
follow();
|
|
851
1079
|
};
|
|
1080
|
+
/** The reader applied an authored frame of `epoch` reaching `version`: the writes it shows are answered; attached, the `scene()` calls waiting too. */
|
|
852
1081
|
const applied = (epoch, version) => {
|
|
853
1082
|
const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
|
|
854
1083
|
waiters = waiters.filter(w => !reached.includes(w));
|
|
855
1084
|
for (const w of reached)
|
|
856
1085
|
w.reply.resolve();
|
|
857
|
-
if (mirror.state
|
|
858
|
-
|
|
1086
|
+
if (mirror.state !== "attached")
|
|
1087
|
+
return;
|
|
1088
|
+
answerJoins();
|
|
1089
|
+
};
|
|
1090
|
+
/**
|
|
1091
|
+
* Reading `attempt` (the one in progress) caught up with incarnation `epoch` at `version`: the claims are made for it
|
|
1092
|
+
* first (`bind`), then the handle is `attached` (one path: nothing a handle does after it sees `attached` meets an
|
|
1093
|
+
* unbound attach). A stale reader's attach is ignored.
|
|
1094
|
+
*/
|
|
1095
|
+
const attach = (attempt, epoch, version) => {
|
|
1096
|
+
const p = ph();
|
|
1097
|
+
if (p.kind !== "following" || p.attempt !== attempt || mirror.epoch !== epoch || !session)
|
|
1098
|
+
return;
|
|
1099
|
+
if (bound?.session !== session || bound.epoch !== epoch)
|
|
1100
|
+
bind(session, epoch);
|
|
1101
|
+
mirror.attach();
|
|
1102
|
+
applied(epoch, version);
|
|
1103
|
+
};
|
|
1104
|
+
// ---- claims (PLAN v0.31)
|
|
1105
|
+
/** 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. */
|
|
1106
|
+
const unbind = () => {
|
|
1107
|
+
bound = undefined;
|
|
1108
|
+
bindNo += 1;
|
|
1109
|
+
asking.clear();
|
|
1110
|
+
// Rows parked for the attach that ended are not this one's data.
|
|
1111
|
+
for (const c of scene.claims.values()) {
|
|
1112
|
+
c.state = UNCLAIMED;
|
|
1113
|
+
c.parked = {};
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
/** 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. */
|
|
1117
|
+
const bind = (s, epoch) => {
|
|
1118
|
+
unbind();
|
|
1119
|
+
bound = { session: s, epoch };
|
|
1120
|
+
for (const [id, c] of [...scene.claims])
|
|
1121
|
+
if (c.isEntity)
|
|
1122
|
+
askAgain(id);
|
|
1123
|
+
const waiting = parkedUpserts;
|
|
1124
|
+
parkedUpserts = [];
|
|
1125
|
+
for (const u of waiting)
|
|
1126
|
+
askFirst(u.args, u.reply);
|
|
1127
|
+
};
|
|
1128
|
+
/** Starts the claim call with `args` in this attach; its answer comes back as `claimDone`. Returns the error when it could not be sent. */
|
|
1129
|
+
const callClaim = (ask, args) => {
|
|
1130
|
+
if (!session || !bound)
|
|
1131
|
+
return detachedError();
|
|
1132
|
+
let call;
|
|
1133
|
+
try {
|
|
1134
|
+
call = startOn(node, session, "synapse/scene/live/upsert", args, {});
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
return asError(e);
|
|
1138
|
+
}
|
|
1139
|
+
const b = bindNo;
|
|
1140
|
+
const s = session;
|
|
1141
|
+
const done = (result) => {
|
|
1142
|
+
if (scene.post({ kind: "claimDone", ask, bind: b, result }))
|
|
1143
|
+
return;
|
|
1144
|
+
// The task is gone (the handle closed): a grant is released, never kept; a `live.upsert` caller is answered from here.
|
|
1145
|
+
if ("ok" in result) {
|
|
1146
|
+
try {
|
|
1147
|
+
release(node, s, replyId(result.ok));
|
|
1148
|
+
}
|
|
1149
|
+
catch { /* not understood: nothing to release */ }
|
|
1150
|
+
}
|
|
1151
|
+
if (ask.kind === "first")
|
|
1152
|
+
ask.reply.reject(closedError(ENDED_CLIENT));
|
|
1153
|
+
};
|
|
1154
|
+
void call.result.then(v => done({ ok: v }), (e) => done({ err: asError(e) }));
|
|
1155
|
+
return undefined;
|
|
1156
|
+
};
|
|
1157
|
+
/** Asks the claim of `id` again with its args (one ask per id at a time). */
|
|
1158
|
+
const askAgain = (id) => {
|
|
1159
|
+
if (!bound || asking.has(id))
|
|
1160
|
+
return;
|
|
1161
|
+
const c = scene.claims.get(id);
|
|
1162
|
+
if (!c)
|
|
1163
|
+
return;
|
|
1164
|
+
c.state = ASKING;
|
|
1165
|
+
asking.add(id);
|
|
1166
|
+
if (callClaim({ kind: "again", id }, { ...c.args, id })) {
|
|
1167
|
+
// Not sent: asked again at the next attach.
|
|
1168
|
+
asking.delete(id);
|
|
1169
|
+
const now = scene.claims.get(id);
|
|
1170
|
+
if (now)
|
|
1171
|
+
now.state = UNCLAIMED;
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
/** 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). */
|
|
1175
|
+
const askFirst = (args, reply) => {
|
|
1176
|
+
const callArgs = { ...args };
|
|
1177
|
+
let named;
|
|
1178
|
+
if (args.name !== undefined)
|
|
1179
|
+
for (const [id, c] of scene.claims)
|
|
1180
|
+
if (c.args.name === args.name) {
|
|
1181
|
+
callArgs.id = named = id;
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
const token = nextFirst++;
|
|
1185
|
+
firsts.set(token, { named, keep: true });
|
|
1186
|
+
const e = callClaim({ kind: "first", args, reply, token }, callArgs);
|
|
1187
|
+
if (e) {
|
|
1188
|
+
firsts.delete(token);
|
|
1189
|
+
reply.reject(e);
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
const upsert = (args, reply) => {
|
|
1193
|
+
if (mirror.state === "closed")
|
|
1194
|
+
reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
|
|
1195
|
+
else if (mirror.state === "detached")
|
|
1196
|
+
reply.reject(detachedError());
|
|
1197
|
+
// Attached: the claims are bound (the task attaches the handle after the bind: one path).
|
|
1198
|
+
else
|
|
1199
|
+
askFirst(args, reply);
|
|
1200
|
+
};
|
|
1201
|
+
/** A lane write asked the claim of `id`: asked now when bound; otherwise unclaimed (the next lane write after the attach asks it). */
|
|
1202
|
+
const claimAsked = (id) => {
|
|
1203
|
+
if (bound) {
|
|
1204
|
+
askAgain(id);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const c = scene.claims.get(id);
|
|
1208
|
+
if (c?.state.kind === "asking")
|
|
1209
|
+
c.state = UNCLAIMED;
|
|
1210
|
+
};
|
|
1211
|
+
/** `live.remove`: the claim dropped; the runtime asked to release it when this attach holds it (or asks it), else answered at once. */
|
|
1212
|
+
const remove = (id, reply) => {
|
|
1213
|
+
const c = scene.claims.get(id);
|
|
1214
|
+
scene.claims.delete(id);
|
|
1215
|
+
asking.delete(id);
|
|
1216
|
+
// A `live.upsert` of this id on the wire: its answer is not kept (granted, it is released).
|
|
1217
|
+
for (const f of firsts.values())
|
|
1218
|
+
if (f.named === id)
|
|
1219
|
+
f.keep = false;
|
|
1220
|
+
const held = !c || c.state.kind === "claimed" || c.state.kind === "asking";
|
|
1221
|
+
if (!session || !bound || !held) {
|
|
1222
|
+
reply.resolve();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
let call;
|
|
1226
|
+
try {
|
|
1227
|
+
call = startOn(node, session, "synapse/scene/live/remove", { id }, {});
|
|
1228
|
+
}
|
|
1229
|
+
catch (e) {
|
|
1230
|
+
reply.reject(asError(e));
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
void call.result.then(() => reply.resolve(), (e) => reply.reject(asError(e)));
|
|
1234
|
+
};
|
|
1235
|
+
/** The rows parked for `id` go out now, as one batch. */
|
|
1236
|
+
const sendParked = (id) => {
|
|
1237
|
+
const c = scene.claims.get(id);
|
|
1238
|
+
if (!c || !session)
|
|
1239
|
+
return;
|
|
1240
|
+
const p = c.parked;
|
|
1241
|
+
c.parked = {};
|
|
1242
|
+
const body = laneBody(p.pose ? [p.pose] : [], p.joints ? [p.joints] : [], p.state ? [p.state] : [], p.lifetimeMs);
|
|
1243
|
+
if (!body)
|
|
1244
|
+
return;
|
|
1245
|
+
// Not sent (the session is going): its end resets the claims, the rows are not re-sent (a batch is never re-sent).
|
|
1246
|
+
try {
|
|
1247
|
+
writeLive(liveWriterOn(scene, session), encodeLive(body));
|
|
1248
|
+
}
|
|
1249
|
+
catch { /* the session's end follows */ }
|
|
1250
|
+
};
|
|
1251
|
+
/** The runtime answered a claim asked in attach `b`. */
|
|
1252
|
+
const claimDone = (ask, b, result) => {
|
|
1253
|
+
if (ask.kind === "first") {
|
|
1254
|
+
let r;
|
|
1255
|
+
if ("ok" in result) {
|
|
1256
|
+
try {
|
|
1257
|
+
r = { ok: replyId(result.ok) };
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
r = { err: asError(e) };
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
else
|
|
1264
|
+
r = result;
|
|
1265
|
+
const f = firsts.get(ask.token);
|
|
1266
|
+
firsts.delete(ask.token);
|
|
1267
|
+
const keep = (f?.keep ?? false) && mirror.state !== "closed";
|
|
1268
|
+
if ("ok" in r && !keep) {
|
|
1269
|
+
// Not kept: a `live.remove` of its id or the handle's close came after it. A grant is released; nothing is kept.
|
|
1270
|
+
release(node, session, r.ok);
|
|
1271
|
+
if (mirror.state === "closed")
|
|
1272
|
+
ask.reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
|
|
1273
|
+
else
|
|
1274
|
+
ask.reply.resolve(r.ok);
|
|
1275
|
+
}
|
|
1276
|
+
else if ("ok" in r) {
|
|
1277
|
+
// Granted in an earlier attach: claimed again in this one.
|
|
1278
|
+
const here = b === bindNo;
|
|
1279
|
+
scene.claims.set(r.ok, new Claim(ask.args, here ? CLAIMED : UNCLAIMED));
|
|
1280
|
+
if (!here)
|
|
1281
|
+
askAgain(r.ok);
|
|
1282
|
+
ask.reply.resolve(r.ok);
|
|
1283
|
+
}
|
|
1284
|
+
else if (sessionGone(r.err)) {
|
|
1285
|
+
// The session went before the runtime answered: asked on the next attach (unless no longer wanted).
|
|
1286
|
+
if (!keep)
|
|
1287
|
+
ask.reply.reject(r.err);
|
|
1288
|
+
else if (b !== bindNo && bound)
|
|
1289
|
+
askFirst(ask.args, ask.reply);
|
|
1290
|
+
else
|
|
1291
|
+
parkedUpserts.push({ args: ask.args, reply: ask.reply });
|
|
1292
|
+
}
|
|
1293
|
+
else
|
|
1294
|
+
ask.reply.reject(r.err);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (b !== bindNo)
|
|
1298
|
+
return;
|
|
1299
|
+
const id = ask.id;
|
|
1300
|
+
asking.delete(id);
|
|
1301
|
+
const c = scene.claims.get(id);
|
|
1302
|
+
if (!c)
|
|
1303
|
+
return;
|
|
1304
|
+
if ("ok" in result) {
|
|
1305
|
+
c.state = CLAIMED;
|
|
1306
|
+
c.refusals = 0;
|
|
1307
|
+
// The rows parked while it was asked go out now.
|
|
1308
|
+
sendParked(id);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const e = result.err;
|
|
1312
|
+
if (sessionGone(e)) {
|
|
1313
|
+
c.state = UNCLAIMED;
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
// The runtime is mid-change: asked again shortly, silently (rows keep parking).
|
|
1317
|
+
if (isTransient(e)) {
|
|
1318
|
+
c.state = { kind: "paced", at: performance.now() + REFOLLOW_PACE_MS, refused: false };
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
c.refusals += 1;
|
|
1322
|
+
const pace = refusalPaceMs(c.refusals);
|
|
1323
|
+
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)`);
|
|
1324
|
+
c.state = { kind: "paced", at: performance.now() + pace, refused: true };
|
|
1325
|
+
const dropped = parkedRows(c.parked);
|
|
1326
|
+
c.parked = {};
|
|
1327
|
+
if (dropped) {
|
|
1328
|
+
scene.unclaimedDrops += dropped;
|
|
1329
|
+
scene.countersChanged();
|
|
1330
|
+
}
|
|
1331
|
+
// An override's refusal is the writer's to see: a node notice (a `not_owner` reaches the denial feeds through it).
|
|
1332
|
+
if (!c.isEntity) {
|
|
1333
|
+
const body = { code: e.code };
|
|
1334
|
+
if (e.message)
|
|
1335
|
+
body.message = e.message;
|
|
1336
|
+
if (e.data !== undefined)
|
|
1337
|
+
body.data = e.data;
|
|
1338
|
+
for (const cb of [...node.notices]) {
|
|
1339
|
+
try {
|
|
1340
|
+
cb(body);
|
|
1341
|
+
}
|
|
1342
|
+
catch (x) {
|
|
1343
|
+
rethrowLater(x);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
/** 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. */
|
|
1349
|
+
const releaseClaims = () => {
|
|
1350
|
+
for (const f of firsts.values())
|
|
1351
|
+
f.keep = false;
|
|
1352
|
+
const claims = [...scene.claims];
|
|
1353
|
+
scene.claims.clear();
|
|
1354
|
+
if (!session || !bound)
|
|
1355
|
+
return;
|
|
1356
|
+
for (const [id, c] of claims) {
|
|
1357
|
+
if (c.state.kind !== "claimed" && c.state.kind !== "asking")
|
|
1358
|
+
continue;
|
|
1359
|
+
try {
|
|
1360
|
+
void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
|
|
1361
|
+
}
|
|
1362
|
+
catch { /* the session's end releases it */ }
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
/** The refused live entities that are due are asked again. */
|
|
1366
|
+
const reclaimDue = () => {
|
|
1367
|
+
const now = performance.now();
|
|
1368
|
+
for (const [id, c] of [...scene.claims]) {
|
|
1369
|
+
const at = timerDue(c);
|
|
1370
|
+
if (at !== undefined && at <= now)
|
|
1371
|
+
askAgain(id);
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
/** The timer for the next refused live entity due to be asked again (none while unbound or nothing paced). */
|
|
1375
|
+
const armReclaim = () => {
|
|
1376
|
+
let at = Infinity;
|
|
1377
|
+
if (bound)
|
|
1378
|
+
for (const c of scene.claims.values()) {
|
|
1379
|
+
const due = timerDue(c);
|
|
1380
|
+
if (due !== undefined && due < at)
|
|
1381
|
+
at = due;
|
|
1382
|
+
}
|
|
1383
|
+
if (at === reclaimAt)
|
|
1384
|
+
return;
|
|
1385
|
+
if (reclaimTimer !== undefined)
|
|
1386
|
+
clearTimeout(reclaimTimer);
|
|
1387
|
+
reclaimTimer = undefined;
|
|
1388
|
+
reclaimAt = at;
|
|
1389
|
+
if (at !== Infinity)
|
|
1390
|
+
reclaimTimer = setTimeout(() => { reclaimAt = Infinity; reclaimTimer = undefined; scene.post({ kind: "reclaim" }); }, Math.max(0, at - performance.now()));
|
|
859
1391
|
};
|
|
860
1392
|
const join = (reply) => { if (mirror.state === "attached" || inactive())
|
|
861
1393
|
reply.resolve();
|
|
@@ -957,6 +1489,9 @@ async function run(scene, registry) {
|
|
|
957
1489
|
case "applied":
|
|
958
1490
|
applied(ev.epoch, ev.version);
|
|
959
1491
|
break;
|
|
1492
|
+
case "attach":
|
|
1493
|
+
attach(ev.attempt, ev.epoch, ev.version);
|
|
1494
|
+
break;
|
|
960
1495
|
case "followEnded": {
|
|
961
1496
|
{
|
|
962
1497
|
const p = ph();
|
|
@@ -993,11 +1528,33 @@ async function run(scene, registry) {
|
|
|
993
1528
|
follow();
|
|
994
1529
|
}
|
|
995
1530
|
break;
|
|
1531
|
+
case "upsert":
|
|
1532
|
+
upsert(ev.args, ev.reply);
|
|
1533
|
+
break;
|
|
1534
|
+
case "remove":
|
|
1535
|
+
remove(ev.id, ev.reply);
|
|
1536
|
+
break;
|
|
1537
|
+
case "claim":
|
|
1538
|
+
claimAsked(ev.id);
|
|
1539
|
+
break;
|
|
1540
|
+
case "claimDone":
|
|
1541
|
+
claimDone(ev.ask, ev.bind, ev.result);
|
|
1542
|
+
break;
|
|
1543
|
+
case "reclaim":
|
|
1544
|
+
reclaimDue();
|
|
1545
|
+
break;
|
|
996
1546
|
case "close": break loop;
|
|
997
1547
|
}
|
|
1548
|
+
armReclaim();
|
|
998
1549
|
}
|
|
999
1550
|
}
|
|
1000
1551
|
finally {
|
|
1552
|
+
// The last handle closed (or the node ended): this node's claims are released (best effort).
|
|
1553
|
+
releaseClaims();
|
|
1554
|
+
if (reclaimTimer !== undefined)
|
|
1555
|
+
clearTimeout(reclaimTimer);
|
|
1556
|
+
for (const u of parkedUpserts.splice(0))
|
|
1557
|
+
u.reply.reject(closedError(reason));
|
|
1001
1558
|
unmonitor();
|
|
1002
1559
|
node.notices.delete(onNotice);
|
|
1003
1560
|
unwatchProject?.();
|
|
@@ -1016,13 +1573,18 @@ async function run(scene, registry) {
|
|
|
1016
1573
|
break;
|
|
1017
1574
|
if (ev.kind === "applyDone")
|
|
1018
1575
|
applyDone(ev.epoch, ev.result, ev.reply);
|
|
1019
|
-
else if (ev.kind === "apply" || ev.kind === "join")
|
|
1576
|
+
else if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "upsert" || ev.kind === "remove")
|
|
1020
1577
|
ev.reply.reject(closedError(reason));
|
|
1578
|
+
else if (ev.kind === "claimDone")
|
|
1579
|
+
claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
|
|
1021
1580
|
}
|
|
1022
1581
|
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")
|
|
1582
|
+
for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext()) {
|
|
1583
|
+
if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone" || ev.kind === "upsert" || ev.kind === "remove")
|
|
1025
1584
|
ev.reply.reject(closedError(reason));
|
|
1585
|
+
else if (ev.kind === "claimDone")
|
|
1586
|
+
claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
|
|
1587
|
+
}
|
|
1026
1588
|
if (registry.current === scene)
|
|
1027
1589
|
registry.current = undefined;
|
|
1028
1590
|
scene.exited.resolve();
|
|
@@ -1144,7 +1706,7 @@ function decodeFrame(p, warn, what) {
|
|
|
1144
1706
|
* its tracks into the mirror, telling the task of each authored frame applied; returns once the
|
|
1145
1707
|
* `authored` track ends (its final frame, or the track going). Once `signal` aborts it writes nothing more.
|
|
1146
1708
|
*/
|
|
1147
|
-
async function followIncarnation(scene, session, announced, skip, signal) {
|
|
1709
|
+
async function followIncarnation(scene, session, announced, skip, signal, attempt) {
|
|
1148
1710
|
const { mirror, warn } = scene;
|
|
1149
1711
|
const none = { epoch: undefined, reason: undefined };
|
|
1150
1712
|
const closers = [announced];
|
|
@@ -1268,8 +1830,8 @@ async function followIncarnation(scene, session, announced, skip, signal) {
|
|
|
1268
1830
|
live.drain(onLive);
|
|
1269
1831
|
if (signal.aborted)
|
|
1270
1832
|
return none;
|
|
1271
|
-
|
|
1272
|
-
scene.post({ kind: "
|
|
1833
|
+
// The task attaches the handle, once the claims are made for this incarnation.
|
|
1834
|
+
scene.post({ kind: "attach", attempt, epoch: at, version: mirror.version });
|
|
1273
1835
|
// Then frame by frame as they come. The live track's end is the incarnation's: the authored track says how it ended.
|
|
1274
1836
|
void live.readOn(onLive);
|
|
1275
1837
|
await authored.readOn(onAuthored);
|
|
@@ -1316,34 +1878,180 @@ export class SceneHandleImpl {
|
|
|
1316
1878
|
/** The views of this handle that follow the mirror now. */
|
|
1317
1879
|
#views = new Set();
|
|
1318
1880
|
#unsubscribes = new Set();
|
|
1881
|
+
/** `ready()` calls waiting for the handle to attach: rejected by `close()`. */
|
|
1882
|
+
#readies = new Set();
|
|
1883
|
+
/** Told when this handle closes (its status says `closed` from then on, whatever the node's other handles do). */
|
|
1884
|
+
#onClose = new Set();
|
|
1885
|
+
/** The authored plane's verbs. */
|
|
1886
|
+
authored;
|
|
1887
|
+
/** This node's live writes. */
|
|
1888
|
+
live;
|
|
1889
|
+
/** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
|
|
1890
|
+
status;
|
|
1891
|
+
#status;
|
|
1319
1892
|
constructor(scene) {
|
|
1320
1893
|
this.scene = scene;
|
|
1321
1894
|
scene.handles++;
|
|
1322
1895
|
this.#id = scene.nextHandle++;
|
|
1896
|
+
this.status = this.#status = new StatusCell(() => this.#statusNow(), l => {
|
|
1897
|
+
const un = scene.mirror.subscribe(l);
|
|
1898
|
+
scene.counters.add(l);
|
|
1899
|
+
this.#onClose.add(l);
|
|
1900
|
+
return () => { un(); scene.counters.delete(l); this.#onClose.delete(l); };
|
|
1901
|
+
}, () => this.#closed || scene.mirror.state === "closed");
|
|
1902
|
+
// One object each for the handle's life.
|
|
1903
|
+
this.authored = Object.freeze({
|
|
1904
|
+
apply: (ops, base) => this.#apply(ops, base),
|
|
1905
|
+
txn: (build) => this.#txn(build),
|
|
1906
|
+
spawn: (kind, opts) => this.#spawn(kind, opts),
|
|
1907
|
+
patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
|
|
1908
|
+
upsert: (kind, opts) => this.#upsert(kind, opts),
|
|
1909
|
+
rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
|
|
1910
|
+
reparent: (entity, parent, place) => this.#txn(t => t.reparent(entity, parent, place)),
|
|
1911
|
+
despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
|
|
1912
|
+
setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
|
|
1913
|
+
});
|
|
1914
|
+
this.live = Object.freeze({
|
|
1915
|
+
batch: (batch) => this.#batch(batch),
|
|
1916
|
+
upsert: (kind, opts, live) => this.#liveUpsert(kind, opts ?? {}, live ?? {}),
|
|
1917
|
+
remove: (entity) => { try {
|
|
1918
|
+
return this.#releaseId(idOf(entity));
|
|
1919
|
+
}
|
|
1920
|
+
catch (e) {
|
|
1921
|
+
return Promise.reject(e);
|
|
1922
|
+
} },
|
|
1923
|
+
});
|
|
1323
1924
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1925
|
+
/** The merged entities now; a closed handle reads nothing (the mirror may live on for the node's other handles). */
|
|
1926
|
+
get() { return this.#closed ? EMPTY : this.scene.mirror.merged; }
|
|
1927
|
+
/** One entity as a view: the merged entity, with its authored value (`authored`) and its live row (`live`). */
|
|
1928
|
+
entity(ref) {
|
|
1326
1929
|
const mirror = this.scene.mirror;
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1930
|
+
const named = this.#idOf(ref);
|
|
1931
|
+
const id = () => {
|
|
1932
|
+
const id = named();
|
|
1933
|
+
if (id === undefined)
|
|
1934
|
+
throw notNamed(ref);
|
|
1935
|
+
return id;
|
|
1936
|
+
};
|
|
1937
|
+
const ended = () => this.#closed || mirror.state === "closed";
|
|
1938
|
+
// Every read goes through the same guard: a closed handle reads nothing (the mirror may live on for the node's other handles).
|
|
1939
|
+
const reader = (read) => () => {
|
|
1940
|
+
if (ended())
|
|
1941
|
+
return undefined;
|
|
1942
|
+
const id = named();
|
|
1943
|
+
return id === undefined ? undefined : read(id);
|
|
1944
|
+
};
|
|
1945
|
+
// The live row, rebuilt only when the row object changes and kept the same object while equal.
|
|
1946
|
+
let from;
|
|
1947
|
+
let last;
|
|
1948
|
+
const ops = {
|
|
1949
|
+
id,
|
|
1950
|
+
// The entity as declared (the authored one, or a live entity's upsert row): its object changes only when an
|
|
1951
|
+
// authored batch or a new upsert changes it, never on a lane tick.
|
|
1952
|
+
readAuthored: reader(id => mirror.authored.get(id) ?? mirror.live.get(id)?.entity),
|
|
1953
|
+
readLive: reader(id => {
|
|
1336
1954
|
const row = mirror.live.get(id);
|
|
1337
1955
|
if (row === from)
|
|
1338
1956
|
return last;
|
|
1339
1957
|
from = row;
|
|
1340
1958
|
last = row ? replaceEqualDeep(last, liveValue(row)) : undefined;
|
|
1341
1959
|
return last;
|
|
1342
|
-
})
|
|
1960
|
+
}),
|
|
1961
|
+
write: components => this.#write(ref, named, components),
|
|
1962
|
+
live: (components, opts) => this.#liveLanes(ref, named, components, opts),
|
|
1963
|
+
remove: () => this.#liveRemove(ref, named),
|
|
1964
|
+
};
|
|
1965
|
+
return new EntityView(mirror, reader(id => mirror.merged[id]), this.#views, ended, ops);
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* The id a view names now: its id, or the id of the entity holding its name (names are unique across every plane;
|
|
1969
|
+
* an authored or calibration entity, else a live entity). The last id found is checked first: a name is held by one
|
|
1970
|
+
* entity at a time.
|
|
1971
|
+
*/
|
|
1972
|
+
#idOf(ref) {
|
|
1973
|
+
if (typeof ref === "string")
|
|
1974
|
+
return () => ref;
|
|
1975
|
+
if (!isObject(ref) || typeof ref.name !== "string")
|
|
1976
|
+
throw new SynapseError("invalid_input", "scene.entity takes an id or { name }");
|
|
1977
|
+
const name = ref.name, mirror = this.scene.mirror;
|
|
1978
|
+
let held;
|
|
1979
|
+
return () => {
|
|
1980
|
+
if (held !== undefined && (mirror.authored.get(held) ?? mirror.live.get(held)?.entity)?.name === name)
|
|
1981
|
+
return held;
|
|
1982
|
+
held = authoredNamed(mirror.authored, name)?.id ?? liveNamed(mirror.live, name);
|
|
1983
|
+
return held;
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/** 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. */
|
|
1987
|
+
async #write(ref, named, components) {
|
|
1988
|
+
if (this.#closed)
|
|
1989
|
+
throw closedError(ENDED_CLIENT);
|
|
1990
|
+
if (this.scene.mirror.state === "detached")
|
|
1991
|
+
throw detachedError();
|
|
1992
|
+
const id = named();
|
|
1993
|
+
if (id === undefined)
|
|
1994
|
+
throw notNamed(ref);
|
|
1995
|
+
await this.#txn(t => t.patch(id, components));
|
|
1996
|
+
}
|
|
1997
|
+
/** An entity view's `live.set`: the lanes of the id it names now, as one batch. */
|
|
1998
|
+
#liveLanes(ref, named, components, opts) {
|
|
1999
|
+
this.#liveOpen();
|
|
2000
|
+
const lanes = {};
|
|
2001
|
+
for (const [k, v] of Object.entries(components)) {
|
|
2002
|
+
if (v === undefined)
|
|
2003
|
+
continue;
|
|
2004
|
+
if (k === "xform") {
|
|
2005
|
+
const x = v;
|
|
2006
|
+
if (!isObject(v) || !("t" in x) || !("q" in x) || Object.keys(x).some(k => k !== "t" && k !== "q"))
|
|
2007
|
+
throw new SynapseError("invalid_input", "scene: live xform is the pose lane: { t, q }, both, nothing else");
|
|
2008
|
+
}
|
|
2009
|
+
else if (k !== "joints" && k !== "state")
|
|
2010
|
+
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 } });
|
|
2011
|
+
lanes[k] = v;
|
|
2012
|
+
}
|
|
2013
|
+
const id = named();
|
|
2014
|
+
if (id === undefined) {
|
|
2015
|
+
// Detached, nothing is named: the write is dropped and counted, as every live write then is.
|
|
2016
|
+
if (this.scene.mirror.state !== "attached") {
|
|
2017
|
+
this.scene.detachedDrops++;
|
|
2018
|
+
this.scene.countersChanged();
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
throw notNamed(ref);
|
|
2022
|
+
}
|
|
2023
|
+
const batch = {};
|
|
2024
|
+
const x = lanes.xform;
|
|
2025
|
+
if (x)
|
|
2026
|
+
batch.poses = [{ id, t: x.t, q: x.q }];
|
|
2027
|
+
if (lanes.joints !== undefined)
|
|
2028
|
+
batch.joints = [{ id, j: lanes.joints }];
|
|
2029
|
+
if (lanes.state !== undefined)
|
|
2030
|
+
batch.state = [{ id, s: lanes.state }];
|
|
2031
|
+
if (opts.lifetimeMs !== undefined)
|
|
2032
|
+
batch.lifetimeMs = opts.lifetimeMs;
|
|
2033
|
+
this.#batch(batch);
|
|
2034
|
+
}
|
|
2035
|
+
/** 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`. */
|
|
2036
|
+
#liveRemove(ref, named) {
|
|
2037
|
+
try {
|
|
2038
|
+
this.#liveOpen();
|
|
2039
|
+
const id = named();
|
|
2040
|
+
if (id === undefined)
|
|
2041
|
+
throw notNamed(ref);
|
|
2042
|
+
return this.#releaseId(id);
|
|
1343
2043
|
}
|
|
1344
|
-
|
|
2044
|
+
catch (e) {
|
|
2045
|
+
return Promise.reject(e);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
/** `live.remove`: the call `synapse/scene/live/remove {id}` through the task (it drops the claim). */
|
|
2049
|
+
#releaseId(id) {
|
|
2050
|
+
if (this.#closed)
|
|
2051
|
+
return Promise.reject(closedError(ENDED_CLIENT));
|
|
2052
|
+
return this.scene.request(reply => ({ kind: "remove", id, reply }));
|
|
1345
2053
|
}
|
|
1346
|
-
tree() { return this.#view(() => this.scene.mirror.tree()); }
|
|
2054
|
+
tree() { return this.#view(() => this.scene.mirror.tree(), EMPTY_TREE); }
|
|
1347
2055
|
query(where) {
|
|
1348
2056
|
let from;
|
|
1349
2057
|
let last = [];
|
|
@@ -1355,13 +2063,40 @@ export class SceneHandleImpl {
|
|
|
1355
2063
|
const ids = Object.keys(m).filter(id => where(m[id])).sort();
|
|
1356
2064
|
last = replaceEqualDeep(last, ids.map(id => m[id]));
|
|
1357
2065
|
return last;
|
|
1358
|
-
});
|
|
2066
|
+
}, EMPTY_LIST);
|
|
2067
|
+
}
|
|
2068
|
+
/** 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. */
|
|
2069
|
+
#view(read, empty) {
|
|
2070
|
+
const ended = () => this.#closed || this.scene.mirror.state === "closed";
|
|
2071
|
+
return new MirrorView(this.scene.mirror, () => (ended() ? empty : read()), this.#views, ended);
|
|
1359
2072
|
}
|
|
1360
|
-
#
|
|
1361
|
-
get status() {
|
|
2073
|
+
#statusNow() {
|
|
1362
2074
|
const m = this.scene.mirror;
|
|
1363
2075
|
const state = this.#closed ? "closed" : m.state;
|
|
1364
|
-
return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 && !m.gap, pending: m.pending, version: m.version, hash: m.hash, detachedDrops: this.scene.detachedDrops };
|
|
2076
|
+
return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 && !m.gap, pending: m.pending, version: m.version, hash: m.hash, detachedDrops: this.scene.detachedDrops, unclaimedDrops: this.scene.unclaimedDrops };
|
|
2077
|
+
}
|
|
2078
|
+
/** The one boolean a node author checks: `status.get().synced`. */
|
|
2079
|
+
get synced() { return this.status.get().synced; }
|
|
2080
|
+
/** Resolves once the handle is attached (at once if it is); `not_connected` once it is closed (`close()`, or the node ending). */
|
|
2081
|
+
ready() {
|
|
2082
|
+
const m = this.scene.mirror;
|
|
2083
|
+
if (this.#closed || m.state === "closed")
|
|
2084
|
+
return Promise.reject(closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT));
|
|
2085
|
+
if (m.state === "attached")
|
|
2086
|
+
return Promise.resolve();
|
|
2087
|
+
const d = deferred();
|
|
2088
|
+
this.#readies.add(d);
|
|
2089
|
+
const un = this.onChange(() => {
|
|
2090
|
+
if (m.state === "closed")
|
|
2091
|
+
d.reject(closedError(m.ended ?? ENDED_CLIENT));
|
|
2092
|
+
else if (m.state === "attached")
|
|
2093
|
+
d.resolve();
|
|
2094
|
+
else
|
|
2095
|
+
return;
|
|
2096
|
+
un();
|
|
2097
|
+
this.#readies.delete(d);
|
|
2098
|
+
});
|
|
2099
|
+
return d.promise;
|
|
1365
2100
|
}
|
|
1366
2101
|
// ---- feeds
|
|
1367
2102
|
#feed(kind, cb) {
|
|
@@ -1385,99 +2120,184 @@ export class SceneHandleImpl {
|
|
|
1385
2120
|
void q.close();
|
|
1386
2121
|
return iterate(q, () => m.removeSink(kind, sink));
|
|
1387
2122
|
}
|
|
1388
|
-
|
|
2123
|
+
#ended() { return this.#closed || this.scene.mirror.state === "closed"; }
|
|
2124
|
+
/** The entities after their next change (state, not history); `not_connected` once this handle is closed. */
|
|
2125
|
+
changed() {
|
|
2126
|
+
const m = this.scene.mirror;
|
|
2127
|
+
const closed = () => closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT);
|
|
2128
|
+
if (this.#ended())
|
|
2129
|
+
return Promise.reject(closed());
|
|
2130
|
+
const from = this.get();
|
|
2131
|
+
const d = deferred();
|
|
2132
|
+
const un = this.onChange(v => {
|
|
2133
|
+
if (this.#ended())
|
|
2134
|
+
d.reject(closed());
|
|
2135
|
+
else if (v !== from)
|
|
2136
|
+
d.resolve(v);
|
|
2137
|
+
else
|
|
2138
|
+
return;
|
|
2139
|
+
un();
|
|
2140
|
+
});
|
|
2141
|
+
return d.promise;
|
|
2142
|
+
}
|
|
2143
|
+
/** The entities after each change; ends with the handle. */
|
|
2144
|
+
watch() { return watchState(cb => this.onChange(cb), () => this.get(), () => this.#ended()); }
|
|
1389
2145
|
watchLiveRemovals(cb) { return this.#feed("removals", cb); }
|
|
1390
2146
|
watchLiveDenied(cb) { return this.#feed("denied", cb); }
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
async apply(ops, base) {
|
|
2147
|
+
// ---- authored (`scene.authored`): every verb is one `apply`
|
|
2148
|
+
async #apply(ops, base) {
|
|
1394
2149
|
if (this.#closed)
|
|
1395
2150
|
throw closedError(ENDED_CLIENT);
|
|
1396
2151
|
if (this.scene.mirror.state === "detached")
|
|
1397
2152
|
throw detachedError();
|
|
1398
2153
|
await this.scene.request(reply => ({ kind: "apply", ops, base, reply }));
|
|
1399
2154
|
}
|
|
1400
|
-
async txn(build) {
|
|
2155
|
+
async #txn(build) {
|
|
1401
2156
|
const t = new Txn();
|
|
1402
2157
|
build(t);
|
|
1403
|
-
await this
|
|
2158
|
+
await this.#apply([...t.ops]);
|
|
1404
2159
|
}
|
|
1405
|
-
async spawn(kind, opts) {
|
|
2160
|
+
async #spawn(kind, opts) {
|
|
1406
2161
|
let id = "";
|
|
1407
|
-
await this
|
|
2162
|
+
await this.#txn(t => { id = t.spawn(kind, opts); });
|
|
1408
2163
|
return id;
|
|
1409
2164
|
}
|
|
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
2165
|
/**
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
2166
|
+
* The entity named `name`, made to carry `components`; its view (by id). None of that name in the authored scene:
|
|
2167
|
+
* `spawn` (with `parent`). One: its authored row (live overrides never count) is compared with what a `patch` of the
|
|
2168
|
+
* components would make of it; equal, nothing is sent; else a `patch` of the components that differ. `parent` is for
|
|
2169
|
+
* the spawn only (an existing entity is not reparented). One of another kind in the mirror: `spawn` too, and the
|
|
2170
|
+
* runtime answers (the mirror may be stale: `name_taken` with `holder` while the name is held, the spawn when it was
|
|
2171
|
+
* freed). An `id` component: `invalid_input` (`data: { component: "id" }`), nothing sent.
|
|
1420
2172
|
*/
|
|
1421
|
-
|
|
2173
|
+
async #upsert(kind, opts) {
|
|
1422
2174
|
if (this.#closed)
|
|
1423
2175
|
throw closedError(ENDED_CLIENT);
|
|
1424
|
-
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
return;
|
|
1429
|
-
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
if (
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
2176
|
+
refuseId(opts, "scene.authored.upsert");
|
|
2177
|
+
const { name, parent: _parent, ...components } = opts;
|
|
2178
|
+
const found = authoredNamed(this.scene.mirror.authored, name);
|
|
2179
|
+
if (!found || found.kind !== kind)
|
|
2180
|
+
return this.entity(await this.#spawn(kind, opts));
|
|
2181
|
+
const row = found;
|
|
2182
|
+
const after = mergeSet(row, components);
|
|
2183
|
+
// Only the components that differ (a patch of `flags` alone is what a locked entity accepts).
|
|
2184
|
+
const differ = Object.fromEntries(Object.entries(components).filter(([k]) => !deepEqual(row[k], after[k])));
|
|
2185
|
+
if (Object.keys(differ).length)
|
|
2186
|
+
await this.#txn(t => t.patch(found.id, differ));
|
|
2187
|
+
return this.entity(found.id);
|
|
2188
|
+
}
|
|
2189
|
+
// ---- live (`scene.live`)
|
|
2190
|
+
/**
|
|
2191
|
+
* A live entity of this node's: the call `synapse/scene/live/upsert {kind, name?, parent, lifetime_ms?, ...components}`
|
|
2192
|
+
* through the task; resolves with the view of the id the runtime answered (a name this node already claimed sends that
|
|
2193
|
+
* claim's id: its own intent). The runtime decides: its refusal is the rejection, as it came, and nothing is kept.
|
|
2194
|
+
* Granted, it is this node's claim, claimed again (same id) on every attach until `remove`. A session that goes before
|
|
2195
|
+
* the answer: asked at the next attach. Detached: `project_inactive`; the node ending: `not_connected`.
|
|
2196
|
+
*/
|
|
2197
|
+
#liveUpsert(kind, opts, live) {
|
|
1444
2198
|
try {
|
|
1445
|
-
|
|
2199
|
+
this.#liveOpen();
|
|
2200
|
+
refuseId(opts, "scene.live.upsert");
|
|
1446
2201
|
}
|
|
1447
2202
|
catch (e) {
|
|
1448
|
-
|
|
2203
|
+
return Promise.reject(e);
|
|
1449
2204
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
let w = scene.writer;
|
|
1453
|
-
if (!w || w.session !== s) {
|
|
1454
|
-
closeLiveWriter(w);
|
|
1455
|
-
try {
|
|
1456
|
-
w = scene.writer = openLiveWriter(s);
|
|
1457
|
-
}
|
|
1458
|
-
catch (e) {
|
|
1459
|
-
scene.writer = undefined;
|
|
1460
|
-
throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
|
|
1464
|
-
w.seq = (w.seq + 1) >>> 0;
|
|
2205
|
+
const { name, parent, ...components } = opts;
|
|
2206
|
+
let parentId;
|
|
1465
2207
|
try {
|
|
1466
|
-
|
|
2208
|
+
parentId = parent == null ? null : idOf(parent, "parent");
|
|
1467
2209
|
}
|
|
1468
2210
|
catch (e) {
|
|
1469
|
-
|
|
2211
|
+
return Promise.reject(e);
|
|
2212
|
+
}
|
|
2213
|
+
const args = {};
|
|
2214
|
+
for (const [k, v] of Object.entries(components))
|
|
2215
|
+
if (v !== undefined)
|
|
2216
|
+
args[k] = v;
|
|
2217
|
+
args.kind = kind;
|
|
2218
|
+
if (name !== undefined)
|
|
2219
|
+
args.name = name;
|
|
2220
|
+
args.parent = parentId;
|
|
2221
|
+
if (live.lifetimeMs !== undefined)
|
|
2222
|
+
args.lifetime_ms = live.lifetimeMs;
|
|
2223
|
+
return this.scene.request(reply => ({ kind: "upsert", args, reply })).then(id => this.entity(id));
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Writes one lane batch on this node's live track (made on the first write of each session); returns at once, like
|
|
2227
|
+
* `publish`. A row goes out only for an id this node claimed in the bound incarnation; the first row of an id with no
|
|
2228
|
+
* claim asks `upsert {id}` (the override) once, and until the answer the latest row of each lane of that id is parked
|
|
2229
|
+
* (latest wins), sent once granted, dropped and counted when refused (`unclaimedDrops`; rows written while refused and
|
|
2230
|
+
* not due again are dropped and counted too). Not attached: dropped and counted (`detachedDrops`). `upsert`/`remove`
|
|
2231
|
+
* keys (the type does not allow them) are sent as given: the runtime refuses such a batch (`invalid_input`, `data {lane}`).
|
|
2232
|
+
*/
|
|
2233
|
+
#batch(batch) {
|
|
2234
|
+
this.#liveOpen();
|
|
2235
|
+
const scene = this.scene;
|
|
2236
|
+
const s = scene.node.currentSession();
|
|
2237
|
+
if (scene.mirror.state !== "attached" || !s) {
|
|
2238
|
+
scene.detachedDrops++;
|
|
2239
|
+
scene.countersChanged();
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
const now = performance.now(), ask = [];
|
|
2243
|
+
const poses = [], joints = [], state = [];
|
|
2244
|
+
let dropped = 0;
|
|
2245
|
+
const lane = (rows, out, park) => {
|
|
2246
|
+
for (const r of rows ?? []) {
|
|
2247
|
+
const g = scene.gate(r.id, now, ask);
|
|
2248
|
+
if (g === "send")
|
|
2249
|
+
out.push(r);
|
|
2250
|
+
else if (g === "park") {
|
|
2251
|
+
const p = scene.claims.get(r.id).parked;
|
|
2252
|
+
park(p, r);
|
|
2253
|
+
p.lifetimeMs = batch.lifetimeMs;
|
|
2254
|
+
}
|
|
2255
|
+
else
|
|
2256
|
+
dropped += 1;
|
|
2257
|
+
}
|
|
2258
|
+
};
|
|
2259
|
+
lane(batch.poses, poses, (p, r) => { p.pose = r; });
|
|
2260
|
+
lane(batch.joints, joints, (p, r) => { p.joints = r; });
|
|
2261
|
+
lane(batch.state, state, (p, r) => { p.state = r; });
|
|
2262
|
+
if (dropped) {
|
|
2263
|
+
scene.unclaimedDrops += dropped;
|
|
2264
|
+
scene.countersChanged();
|
|
1470
2265
|
}
|
|
2266
|
+
for (const id of ask)
|
|
2267
|
+
scene.post({ kind: "claim", id });
|
|
2268
|
+
const body = laneBody(poses, joints, state, batch.lifetimeMs) ?? {};
|
|
2269
|
+
const rest = batch;
|
|
2270
|
+
if (rest.upsert !== undefined)
|
|
2271
|
+
body.upsert = rest.upsert;
|
|
2272
|
+
if (rest.remove !== undefined)
|
|
2273
|
+
body.remove = rest.remove;
|
|
2274
|
+
if (!Object.keys(body).length)
|
|
2275
|
+
return;
|
|
2276
|
+
if (batch.lifetimeMs !== undefined)
|
|
2277
|
+
body.lifetime_ms = batch.lifetimeMs;
|
|
2278
|
+
writeLive(liveWriterOn(scene, s), encodeLive(body));
|
|
2279
|
+
}
|
|
2280
|
+
/** 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. */
|
|
2281
|
+
#liveOpen() {
|
|
2282
|
+
if (this.#closed)
|
|
2283
|
+
throw closedError(ENDED_CLIENT);
|
|
2284
|
+
if (this.scene.node.isEnded)
|
|
2285
|
+
throw closedError(ENDED_CONNECTION_LOST);
|
|
2286
|
+
const m = this.scene.mirror;
|
|
2287
|
+
if (m.state === "closed")
|
|
2288
|
+
throw closedError(m.ended ?? ENDED_CLIENT);
|
|
1471
2289
|
}
|
|
1472
|
-
/**
|
|
1473
|
-
|
|
2290
|
+
/** `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. */
|
|
2291
|
+
onChange(cb) {
|
|
1474
2292
|
if (this.#closed)
|
|
1475
2293
|
return () => { };
|
|
1476
|
-
const
|
|
2294
|
+
const l = () => cb(this.get());
|
|
2295
|
+
const un = this.scene.mirror.subscribe(l);
|
|
1477
2296
|
this.#unsubscribes.add(un);
|
|
1478
|
-
|
|
2297
|
+
this.#onClose.add(l);
|
|
2298
|
+
return () => { un(); this.#unsubscribes.delete(un); this.#onClose.delete(l); };
|
|
1479
2299
|
}
|
|
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
|
|
2300
|
+
/** 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
2301
|
close() {
|
|
1482
2302
|
if (this.#closed)
|
|
1483
2303
|
return;
|
|
@@ -1488,6 +2308,19 @@ export class SceneHandleImpl {
|
|
|
1488
2308
|
for (const un of this.#unsubscribes)
|
|
1489
2309
|
un();
|
|
1490
2310
|
this.#unsubscribes.clear();
|
|
2311
|
+
for (const d of this.#readies)
|
|
2312
|
+
d.reject(closedError(ENDED_CLIENT));
|
|
2313
|
+
this.#readies.clear();
|
|
2314
|
+
for (const l of [...this.#onClose]) {
|
|
2315
|
+
try {
|
|
2316
|
+
l();
|
|
2317
|
+
}
|
|
2318
|
+
catch (e) {
|
|
2319
|
+
rethrowLater(e);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
this.#onClose.clear();
|
|
2323
|
+
this.#status.dispose();
|
|
1491
2324
|
if (--this.scene.handles === 0)
|
|
1492
2325
|
this.scene.post({ kind: "close" });
|
|
1493
2326
|
}
|
|
@@ -1496,11 +2329,10 @@ export class SceneHandleImpl {
|
|
|
1496
2329
|
/**
|
|
1497
2330
|
* The active project's scene (one per project; one task per node, shared by every handle). Resolves
|
|
1498
2331
|
* once the handle is attached, or at once `detached` when the runtime says no project is active; the
|
|
1499
|
-
* handle then follows the incarnations as they come and go.
|
|
2332
|
+
* handle then follows the incarnations as they come and go. Without a session (before the first, between
|
|
2333
|
+
* two) the task waits for the next one, like after a drop.
|
|
1500
2334
|
*/
|
|
1501
2335
|
export async function openScene(node, registry, warn) {
|
|
1502
|
-
if (!node.currentSession())
|
|
1503
|
-
throw new SynapseError("not_connected");
|
|
1504
2336
|
let scene;
|
|
1505
2337
|
let h;
|
|
1506
2338
|
for (;;) {
|