mandala-computer-mcp 0.1.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -24
- package/dist/api.d.ts +19 -6
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +355 -76
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +113 -8
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +100 -12
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +169 -29
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +45 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +422 -114
- package/dist/events.js.map +1 -1
- package/dist/format.d.ts +48 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +111 -4
- package/dist/format.js.map +1 -1
- package/dist/http-body.d.ts +17 -0
- package/dist/http-body.d.ts.map +1 -0
- package/dist/http-body.js +48 -0
- package/dist/http-body.js.map +1 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +177 -51
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +17 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/limits.js.map +1 -0
- package/dist/paths.d.ts +30 -20
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +89 -25
- package/dist/paths.js.map +1 -1
- package/dist/poll.d.ts +107 -0
- package/dist/poll.d.ts.map +1 -0
- package/dist/poll.js +233 -0
- package/dist/poll.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +72 -5
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +559 -233
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +359 -69
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +234 -33
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +92 -8
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +501 -33
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +61 -26
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.d.ts.map +1 -1
- package/dist/tools/webhooks.js +116 -17
- package/dist/tools/webhooks.js.map +1 -1
- package/package.json +3 -2
package/dist/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a computer is doing, held open on this side of the model.
|
|
3
3
|
*
|
|
4
|
-
* `GET computers/:id/events` (
|
|
4
|
+
* `GET computers/:id/events` (OPL-3785) is a websocket that says what
|
|
5
5
|
* a computer is doing without being asked, so that an agent stops paying for a
|
|
6
6
|
* screenshot to learn that nothing has changed. Every other client of that
|
|
7
7
|
* stream hands it to its caller as an iterator, because every other caller sits
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
* doorbell an addition to this file rather than a replacement for it, and one
|
|
26
26
|
* whose bell nothing on the other end rings yet.
|
|
27
27
|
*
|
|
28
|
-
* Written against the `events_url` entry in the platform's
|
|
29
|
-
* which is
|
|
28
|
+
* Written against the `events_url` entry in the platform's API reference,
|
|
29
|
+
* which is what this must not contradict.
|
|
30
30
|
*/
|
|
31
31
|
import { posix } from 'node:path';
|
|
32
32
|
import { WebSocket as UndiciWebSocket } from 'undici';
|
|
33
33
|
import { isTransientForPoll, MandalaError } from './errors.js';
|
|
34
|
-
import { unwrapComputer } from './format.js';
|
|
34
|
+
import { nothingAdmitted, unwrapComputer } from './format.js';
|
|
35
35
|
import * as P from './paths.js';
|
|
36
36
|
/**
|
|
37
37
|
* undici's `WebSocket`, not Node's global one.
|
|
@@ -179,6 +179,8 @@ export class Subscription {
|
|
|
179
179
|
#nextIndex = 0;
|
|
180
180
|
/** The index after the last event handed to the model. */
|
|
181
181
|
#delivered = 0;
|
|
182
|
+
/** The cursor immediately before the oldest event still in the ring. */
|
|
183
|
+
#beforeOldestCursor;
|
|
182
184
|
/** Set once the model has read anything at all, so a first read can say so. */
|
|
183
185
|
#read = false;
|
|
184
186
|
#loss;
|
|
@@ -196,6 +198,8 @@ export class Subscription {
|
|
|
196
198
|
/** The cursor after the last event actually handed to the model. */
|
|
197
199
|
#deliveredCursor;
|
|
198
200
|
#hello;
|
|
201
|
+
/** The buffer position after the greeting's cursor, retained across eviction. */
|
|
202
|
+
#helloFrom = 0;
|
|
199
203
|
#types;
|
|
200
204
|
/**
|
|
201
205
|
* The trees nominated on this stream, oldest nomination first.
|
|
@@ -238,6 +242,12 @@ export class Subscription {
|
|
|
238
242
|
* changed.
|
|
239
243
|
*/
|
|
240
244
|
#armGen = new Map();
|
|
245
|
+
/** Next subscription-wide arm identity; active paths alone retain theirs. */
|
|
246
|
+
#nextArmGen = 0;
|
|
247
|
+
/** Trees that have armed at least once in this subscription. */
|
|
248
|
+
#everArmed = new Set();
|
|
249
|
+
/** Re-arms a file-wait response has not explained yet. */
|
|
250
|
+
#undisclosedRearm = new Set();
|
|
241
251
|
/**
|
|
242
252
|
* The STANDING loss on each tree, cleared when it arms.
|
|
243
253
|
*
|
|
@@ -266,29 +276,19 @@ export class Subscription {
|
|
|
266
276
|
* Trees a connection carrying them would not open, PROVEN so.
|
|
267
277
|
*
|
|
268
278
|
* Proven means the experiment came back positive — see {@link #cleared}: the
|
|
269
|
-
* tree
|
|
270
|
-
*
|
|
271
|
-
* {@link #shedCandidate} and is not in here.
|
|
279
|
+
* tree failed when added to a known working set, then that same set opened
|
|
280
|
+
* without it. A tree still being tested is not in here.
|
|
272
281
|
*/
|
|
273
282
|
#watchRefused = new Set();
|
|
274
283
|
/**
|
|
275
|
-
*
|
|
276
|
-
* reason nothing will open. `undefined` when no experiment is running.
|
|
284
|
+
* A bounded search for nominations the host will not carry.
|
|
277
285
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* a
|
|
286
|
+
* The last successful set is tried first. Once that opens, each addition is
|
|
287
|
+
* added back alone. A failed addition is removed once more for confirmation,
|
|
288
|
+
* so a host outage during the probe cannot turn an innocent tree into a
|
|
289
|
+
* refusal. Nominations stay in {@link #watches} throughout the experiment.
|
|
281
290
|
*/
|
|
282
|
-
#
|
|
283
|
-
/**
|
|
284
|
-
* Whether a watch has been ruled out as the reason connections are failing.
|
|
285
|
-
*
|
|
286
|
-
* Set when an experiment comes back negative, cleared the moment anything
|
|
287
|
-
* greets. Without it a host that is simply DOWN would shed its way through
|
|
288
|
-
* every tree in the set two failures at a time, reporting each in turn as one
|
|
289
|
-
* the host would not carry.
|
|
290
|
-
*/
|
|
291
|
-
#shedRuledOut = false;
|
|
291
|
+
#watchRecovery;
|
|
292
292
|
/** What the last connection that reached an opening frame was carrying. */
|
|
293
293
|
#lastGood = [];
|
|
294
294
|
/** Consecutive connections that never reached an opening frame while watching. */
|
|
@@ -318,6 +318,8 @@ export class Subscription {
|
|
|
318
318
|
*/
|
|
319
319
|
#sent = [];
|
|
320
320
|
#greeted = false;
|
|
321
|
+
/** Monotonic so overlapping waits can each detect a connection interruption. */
|
|
322
|
+
#connectionVersion = 0;
|
|
321
323
|
/**
|
|
322
324
|
* Whether the socket now closing was closed by THIS side to re-nominate.
|
|
323
325
|
*
|
|
@@ -384,6 +386,13 @@ export class Subscription {
|
|
|
384
386
|
get state() {
|
|
385
387
|
return this.#state;
|
|
386
388
|
}
|
|
389
|
+
/** Cached capabilities survive a disconnect; this describes the current connection. */
|
|
390
|
+
get connected() {
|
|
391
|
+
return this.#greeted && this.#state.status === 'open';
|
|
392
|
+
}
|
|
393
|
+
get connectionVersion() {
|
|
394
|
+
return this.#connectionVersion;
|
|
395
|
+
}
|
|
387
396
|
get idleMs() {
|
|
388
397
|
return Date.now() - this.#lastUsed;
|
|
389
398
|
}
|
|
@@ -421,6 +430,14 @@ export class Subscription {
|
|
|
421
430
|
armGeneration(path) {
|
|
422
431
|
return this.#armGen.get(path) ?? 0;
|
|
423
432
|
}
|
|
433
|
+
/** Whether a completed re-arm still needs to be explained to this tree's caller. */
|
|
434
|
+
hasUndisclosedRearm(path) {
|
|
435
|
+
return this.#undisclosedRearm.has(path);
|
|
436
|
+
}
|
|
437
|
+
/** Claim a re-arm for the response that is about to explain it. */
|
|
438
|
+
takeUndisclosedRearm(path) {
|
|
439
|
+
return this.#undisclosedRearm.delete(path);
|
|
440
|
+
}
|
|
424
441
|
/** The last thing this tree said it had lost, if it has said one since arming. */
|
|
425
442
|
lostFor(path) {
|
|
426
443
|
return this.#watchLost.get(path);
|
|
@@ -508,11 +525,36 @@ export class Subscription {
|
|
|
508
525
|
// its tree limit, and the directory may since exist. "Call again" is the
|
|
509
526
|
// advice every one of those refusals gives, so calling again has to mean
|
|
510
527
|
// something.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
528
|
+
// `unwatchable` belongs in this list and was the one condition missing from
|
|
529
|
+
// it. It is the only standing loss a nomination can lift — the guest decides
|
|
530
|
+
// watchability when it is ASKED, which is at nomination time, so a directory
|
|
531
|
+
// that has since been created is answered for only by asking again. Left
|
|
532
|
+
// out, the tree stayed in `#sent`, the early return below skipped the
|
|
533
|
+
// reopen, and the clearing on the next connection's `hello` was unreachable:
|
|
534
|
+
// `wait_for_file_change` refused every later call on a tree that was by then
|
|
535
|
+
// perfectly watchable, while its own refusal ended by promising that calling
|
|
536
|
+
// again would find it armed.
|
|
537
|
+
//
|
|
538
|
+
// Deleted here, exactly as `#watchRefused` is, and for the same reason: the
|
|
539
|
+
// reopen below IS the act of asking again, so the old answer must not still
|
|
540
|
+
// be standing while the new one is in flight. Left in place it reopens and
|
|
541
|
+
// then defeats itself — `armedWait` returns on a standing `unwatchable`
|
|
542
|
+
// before the new connection can greet, so the retry costs a round trip and
|
|
543
|
+
// changes nothing. Nothing is carried forward wrongly by dropping it: a
|
|
544
|
+
// guest whose directory is still missing answers `unwatchable` again on the
|
|
545
|
+
// connection this opens, and a reopen that never greets leaves the wait to
|
|
546
|
+
// end on its own deadline, which is what the refused retry beside it does.
|
|
547
|
+
const unwatchable = this.#watchLost.get(path) === 'unwatchable';
|
|
548
|
+
if (unwatchable)
|
|
549
|
+
this.#watchLost.delete(path);
|
|
550
|
+
// A fresh request supersedes an in-flight diagnosis. The next connection
|
|
551
|
+
// carries the newly requested set and, if needed, diagnoses that set from
|
|
552
|
+
// the beginning rather than applying a conclusion to a set that changed.
|
|
553
|
+
const recovering = this.#watchRecovery !== undefined;
|
|
554
|
+
if (recovering)
|
|
555
|
+
this.#watchRecovery = undefined;
|
|
556
|
+
const retrying = this.#watchRefused.delete(path) || recovering || unwatchable;
|
|
514
557
|
this.#upgradeFailures = 0;
|
|
515
|
-
this.#shedRuledOut = false;
|
|
516
558
|
const at = this.#watches.indexOf(path);
|
|
517
559
|
if (at >= 0) {
|
|
518
560
|
// Most recently asked about goes last, so the eviction below always takes
|
|
@@ -525,8 +567,7 @@ export class Subscription {
|
|
|
525
567
|
// reopen for. A tree being retried is neither.
|
|
526
568
|
if (!retrying && this.#sent.includes(path))
|
|
527
569
|
return {};
|
|
528
|
-
this.#
|
|
529
|
-
this.#socket?.close();
|
|
570
|
+
this.#reopen();
|
|
530
571
|
this.#wakeAll();
|
|
531
572
|
return {};
|
|
532
573
|
}
|
|
@@ -539,29 +580,42 @@ export class Subscription {
|
|
|
539
580
|
this.#watchLost.delete(evicted);
|
|
540
581
|
this.#hostName.delete(evicted);
|
|
541
582
|
this.#interrupted.delete(evicted);
|
|
583
|
+
this.#everArmed.delete(evicted);
|
|
584
|
+
this.#undisclosedRearm.delete(evicted);
|
|
542
585
|
this.#watchRefused.delete(evicted);
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
this.#shedCandidate = undefined;
|
|
548
|
-
// The arm generation is deliberately NOT deleted. It has to stay
|
|
549
|
-
// monotonic per path, because a waiter parked on this tree is holding a
|
|
550
|
-
// number from before the eviction: reset to zero and re-nominated, the
|
|
551
|
-
// tree would come back at one and that waiter would read an eviction as
|
|
552
|
-
// a re-arm — "reporting starts here, re-read the tree" about a tree that
|
|
553
|
-
// had simply been taken away from it. Eviction is told by membership,
|
|
554
|
-
// which is what `nominates` is for.
|
|
586
|
+
// Historical paths retain no metadata. Generation identities come from
|
|
587
|
+
// a subscription-wide counter, so deleting this entry still cannot let
|
|
588
|
+
// an older waiter confuse a later re-nomination with its original arm.
|
|
589
|
+
this.#armGen.delete(evicted);
|
|
555
590
|
}
|
|
556
591
|
}
|
|
557
|
-
|
|
558
|
-
// reads `#watches` for the new connection. The flag is what keeps that turn
|
|
559
|
-
// from paying the reconnect floor for a reconnection this side chose.
|
|
560
|
-
this.#renominate = true;
|
|
561
|
-
this.#socket?.close();
|
|
592
|
+
this.#reopen();
|
|
562
593
|
this.#wakeAll();
|
|
563
594
|
return { evicted };
|
|
564
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* End the current connection so the next one carries the new watch set.
|
|
598
|
+
*
|
|
599
|
+
* Closed rather than aborted: `#loop` is still running and its next turn
|
|
600
|
+
* reads `#watches` for the new connection. `#renominate` is what keeps that
|
|
601
|
+
* turn from paying the reconnect floor for a reconnection this side chose.
|
|
602
|
+
*
|
|
603
|
+
* The flag is set ONLY when there is a socket to close, because that is what
|
|
604
|
+
* it claims: the connection now ending was ended by this side. Between
|
|
605
|
+
* connections there is none — `finish()` clears `#socket` the moment one
|
|
606
|
+
* ends, and the loop then spends a `GET /computers/:id` and a sleep before
|
|
607
|
+
* assigning the next — and a flag set in that window is consumed by the
|
|
608
|
+
* outcome of a connection this side did not cut short. That connection's
|
|
609
|
+
* failure to reach its opening frame is genuine evidence about the host, and
|
|
610
|
+
* swallowing it skips the backoff step, the `#blamed()` that would eventually
|
|
611
|
+
* shed the tree responsible, and the floor in front of the immediate retry.
|
|
612
|
+
*/
|
|
613
|
+
#reopen() {
|
|
614
|
+
if (!this.#socket)
|
|
615
|
+
return;
|
|
616
|
+
this.#renominate = true;
|
|
617
|
+
this.#socket.close();
|
|
618
|
+
}
|
|
565
619
|
/** Open the socket, if it is not already open. Returns at once. */
|
|
566
620
|
start() {
|
|
567
621
|
if (this.#running)
|
|
@@ -594,7 +648,12 @@ export class Subscription {
|
|
|
594
648
|
this.touch();
|
|
595
649
|
const attached = !this.#read;
|
|
596
650
|
this.#read = true;
|
|
597
|
-
|
|
651
|
+
// The one place a gap the `since` cursor implies is turned into a standing
|
|
652
|
+
// loss, because this is the call that hands it to the model. Establishing it
|
|
653
|
+
// in the placement itself would stamp it on the subscription from every
|
|
654
|
+
// preview — and a preview whose caller then cancels, or a concurrent read on
|
|
655
|
+
// the same subscription, would take the gap away with it.
|
|
656
|
+
const { from, loss: standing } = this.#place(opts.since);
|
|
598
657
|
const end = opts.through !== undefined ? opts.through + 1 : this.#nextIndex;
|
|
599
658
|
const window = this.#ring.filter((b) => b.index >= from && b.index < end);
|
|
600
659
|
// The OLDEST `limit`, not the newest, and the position advances only over
|
|
@@ -625,12 +684,12 @@ export class Subscription {
|
|
|
625
684
|
// An unknown count plus a known one is still unknown. Adding the two
|
|
626
685
|
// would report a precise number for a hole nobody can measure, which
|
|
627
686
|
// is the one thing a loss report must not do.
|
|
628
|
-
events:
|
|
629
|
-
reason:
|
|
630
|
-
? `${
|
|
687
|
+
events: standing?.events === null ? null : (standing?.events ?? 0) + omitted,
|
|
688
|
+
reason: standing
|
|
689
|
+
? `${standing.reason}; and ${omitted} more than limit allowed were stepped over to reach the event you waited for`
|
|
631
690
|
: `${omitted} events older than the one you waited for did not fit in limit and were stepped over`,
|
|
632
691
|
}
|
|
633
|
-
:
|
|
692
|
+
: standing;
|
|
634
693
|
this.#loss = undefined;
|
|
635
694
|
return {
|
|
636
695
|
events: batch.map((b) => b.event),
|
|
@@ -641,6 +700,33 @@ export class Subscription {
|
|
|
641
700
|
attached,
|
|
642
701
|
};
|
|
643
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Whether this read needs the current computer state attached to its loss.
|
|
705
|
+
*
|
|
706
|
+
* Asked before {@link read}, because reconciliation awaits network reads and
|
|
707
|
+
* a caller can cancel during them. Consuming first would advance the shared
|
|
708
|
+
* cursor and clear the loss into a response that caller never receives.
|
|
709
|
+
*
|
|
710
|
+
* A pure preview, and that is load-bearing rather than tidiness. It sees the
|
|
711
|
+
* gap an unplaceable `since` implies, but it does not record one: a subscription
|
|
712
|
+
* is shared by every call on its computer, so a predicate that stamped `#loss`
|
|
713
|
+
* would hand that gap to whichever call read next — the cancelled caller's hole
|
|
714
|
+
* reported against a later read that named a different cursor, or none at all.
|
|
715
|
+
*/
|
|
716
|
+
needsReconciliation(opts) {
|
|
717
|
+
const { from, loss } = this.#place(opts.since);
|
|
718
|
+
if (loss)
|
|
719
|
+
return true;
|
|
720
|
+
if (opts.through === undefined)
|
|
721
|
+
return false;
|
|
722
|
+
const end = opts.through + 1;
|
|
723
|
+
let count = 0;
|
|
724
|
+
for (const buffered of this.#ring) {
|
|
725
|
+
if (buffered.index >= from && buffered.index < end)
|
|
726
|
+
count++;
|
|
727
|
+
}
|
|
728
|
+
return count > opts.limit;
|
|
729
|
+
}
|
|
644
730
|
/**
|
|
645
731
|
* Where a read or a wait starts: the model's own place, or the cursor it named.
|
|
646
732
|
*
|
|
@@ -650,33 +736,75 @@ export class Subscription {
|
|
|
650
736
|
* server holds, and saying so is the whole of the gap discipline — never a
|
|
651
737
|
* frame the model has to interpret, always a sentence and, from the tool, the
|
|
652
738
|
* state it would otherwise have gone to reconcile against.
|
|
739
|
+
*
|
|
740
|
+
* Reads nothing back into the subscription: see {@link #place}, which is where
|
|
741
|
+
* the sentence that gap discipline owes the model is composed, and {@link read},
|
|
742
|
+
* which is the only caller allowed to make it standing.
|
|
653
743
|
*/
|
|
654
744
|
resolveFrom(since) {
|
|
745
|
+
return this.#place(since).from;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Placing a `since` cursor, and the loss a read from there would report.
|
|
749
|
+
*
|
|
750
|
+
* Pure. The returned `loss` is the standing loss MERGED with whatever this
|
|
751
|
+
* cursor implies, so a caller that reports it reports one hole rather than two
|
|
752
|
+
* overlapping accounts of the same evicted prefix — and a caller that only
|
|
753
|
+
* wanted the position, or only wanted to know whether there is a hole, leaves
|
|
754
|
+
* the subscription exactly as it found it.
|
|
755
|
+
*/
|
|
756
|
+
#place(since) {
|
|
655
757
|
// Where the model is, which is not where the socket is: it may be four
|
|
656
758
|
// turns behind, and everything between the two is exactly what it has not
|
|
657
759
|
// been handed yet.
|
|
658
|
-
if (since === undefined)
|
|
659
|
-
return Math.max(this.#delivered, this.#oldest);
|
|
760
|
+
if (since === undefined) {
|
|
761
|
+
return { from: Math.max(this.#delivered, this.#oldest), loss: this.#loss };
|
|
762
|
+
}
|
|
660
763
|
const at = this.#ring.findIndex((b) => b.event.cursor === since);
|
|
661
764
|
if (at >= 0)
|
|
662
|
-
return this.#ring[at].index + 1;
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// deliberate rewind
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
765
|
+
return { from: this.#ring[at].index + 1, loss: this.#loss };
|
|
766
|
+
// An opening cursor can outlive everything that followed it in the ring.
|
|
767
|
+
// Evicting already delivered events is harmless for implicit reads, but a
|
|
768
|
+
// deliberate rewind asks for those events again and must hear about the hole.
|
|
769
|
+
if (since === this.#hello?.cursor) {
|
|
770
|
+
const missing = Math.max(0, this.#oldest - this.#helloFrom);
|
|
771
|
+
const loss = missing && this.#loss?.events !== null
|
|
772
|
+
? {
|
|
773
|
+
// Unread overflow and this rewind describe overlapping evicted
|
|
774
|
+
// prefixes. Count their union, rather than adding the same loss twice.
|
|
775
|
+
events: Math.max(missing, this.#loss?.events ?? 0),
|
|
776
|
+
reason: 'events requested from that opening cursor were dropped from this session’s buffer',
|
|
777
|
+
}
|
|
778
|
+
: this.#loss;
|
|
779
|
+
return { from: Math.max(this.#helloFrom, this.#oldest), loss };
|
|
780
|
+
}
|
|
781
|
+
// An empty `through` read can advance the delivery position to the oldest
|
|
782
|
+
// survivor without handing over an event. Its cursor is the predecessor
|
|
783
|
+
// retained during eviction, which is no longer in the ring but is still an
|
|
784
|
+
// exact, lossless place from which to resume those survivors.
|
|
785
|
+
if (since === this.#beforeOldestCursor)
|
|
786
|
+
return { from: this.#oldest, loss: this.#loss };
|
|
669
787
|
// Otherwise: the unread frontier, NOT the oldest thing still in the ring.
|
|
670
788
|
// A delivered event stays in the ring until the cap evicts it, so answering
|
|
671
789
|
// an unplaceable cursor with `#oldest` re-sent events the model already
|
|
672
790
|
// had — while attaching a loss note that said they "were not kept", which
|
|
673
791
|
// was false about exactly the events being re-sent.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
792
|
+
//
|
|
793
|
+
// `events: null` whatever else is standing, and that is the same rule
|
|
794
|
+
// {@link read} applies to its own overflow: a cursor this session cannot
|
|
795
|
+
// place may be any distance back, so a count taken from this buffer offered
|
|
796
|
+
// as the size of that hole would be a precise answer to a question nobody
|
|
797
|
+
// can measure. The standing reason is kept alongside, because an eviction
|
|
798
|
+
// here and an unplaceable cursor are two true things about one gap.
|
|
799
|
+
const unplaceable = 'that cursor is not a place this session can find, so whatever happened between it and ' +
|
|
800
|
+
'the events below was not kept here';
|
|
801
|
+
return {
|
|
802
|
+
from: Math.max(this.#delivered, this.#oldest),
|
|
803
|
+
loss: {
|
|
804
|
+
events: null,
|
|
805
|
+
reason: this.#loss ? `${this.#loss.reason}; and ${unplaceable}` : unplaceable,
|
|
806
|
+
},
|
|
678
807
|
};
|
|
679
|
-
return Math.max(this.#delivered, this.#oldest);
|
|
680
808
|
}
|
|
681
809
|
/**
|
|
682
810
|
* Wait for an event this predicate accepts, or for the deadline.
|
|
@@ -814,12 +942,42 @@ export class Subscription {
|
|
|
814
942
|
cancel?.addEventListener('abort', done, { once: true });
|
|
815
943
|
});
|
|
816
944
|
}
|
|
817
|
-
/**
|
|
945
|
+
/**
|
|
946
|
+
* The cursor for a position — WHERE THE CALLER IS, which is not always where
|
|
947
|
+
* the socket is.
|
|
948
|
+
*
|
|
949
|
+
* The last event handed over answers it whenever there was one. When there was
|
|
950
|
+
* not, the answer is still the model's own place and not `#resume`, which is
|
|
951
|
+
* after the last event this connection RECEIVED. The two are the same on an
|
|
952
|
+
* ordinary empty poll, and that is why the difference went unnoticed: no
|
|
953
|
+
* `through`, an empty window means the position has caught up with the ring.
|
|
954
|
+
*
|
|
955
|
+
* A `through` read is the case where they part. A wait resolves on an event
|
|
956
|
+
* and reads up to it, and by then another call on the same computer — which
|
|
957
|
+
* these tools are built to allow — may already have taken it: the window comes
|
|
958
|
+
* back empty while `more` is still positive. Answering `#resume` there hands
|
|
959
|
+
* back `more_waiting: N` beside a cursor that, passed in as `since`, resolves
|
|
960
|
+
* PAST those N events, with no loss to say they were skipped. So the position
|
|
961
|
+
* is read off the ring at `#delivered`, which is the one place that agrees
|
|
962
|
+
* with the `more` in the same answer.
|
|
963
|
+
*/
|
|
818
964
|
#position(last) {
|
|
819
965
|
const cursor = last?.event.cursor;
|
|
820
966
|
if (typeof cursor === 'string' && cursor)
|
|
821
967
|
return cursor;
|
|
822
|
-
|
|
968
|
+
const at = this.#ring.find((b) => b.index === this.#delivered - 1)?.event.cursor;
|
|
969
|
+
if (typeof at === 'string' && at)
|
|
970
|
+
return at;
|
|
971
|
+
if (this.#delivered === this.#oldest && this.#beforeOldestCursor) {
|
|
972
|
+
return this.#beforeOldestCursor;
|
|
973
|
+
}
|
|
974
|
+
// Nothing in the ring to place it by: either nothing has been delivered on
|
|
975
|
+
// this stream at all, or the event the position sits after has been evicted.
|
|
976
|
+
// The last cursor DELIVERED, or the frontier where this subscription
|
|
977
|
+
// started before anything has been handed over. Both follow the same rule
|
|
978
|
+
// as {@link resumeCursor}: a position behind the receive head may replay,
|
|
979
|
+
// while a position ahead of unread records silently skips them.
|
|
980
|
+
return this.#deliveredCursor ?? this.#start ?? this.#hello?.cursor ?? '';
|
|
823
981
|
}
|
|
824
982
|
#wakeAll() {
|
|
825
983
|
for (const wake of [...this.#wake])
|
|
@@ -827,8 +985,15 @@ export class Subscription {
|
|
|
827
985
|
}
|
|
828
986
|
#push(event) {
|
|
829
987
|
this.#ring.push({ index: this.#nextIndex++, event });
|
|
988
|
+
// A resumed stream may replay the greeting's cursor after the greeting.
|
|
989
|
+
// Once that event arrives, its position is stronger than the initial frontier.
|
|
990
|
+
if (event.cursor === this.#hello?.cursor)
|
|
991
|
+
this.#helloFrom = this.#nextIndex;
|
|
830
992
|
if (this.#ring.length > MAX_BUFFERED) {
|
|
831
993
|
const evicted = this.#ring.splice(0, this.#ring.length - MAX_BUFFERED);
|
|
994
|
+
const beforeOldest = evicted[evicted.length - 1]?.event.cursor;
|
|
995
|
+
this.#beforeOldestCursor =
|
|
996
|
+
typeof beforeOldest === 'string' && beforeOldest ? beforeOldest : undefined;
|
|
832
997
|
// Only what the model had not been handed is a loss. Dropping events it
|
|
833
998
|
// already read is the ring doing its job, and counting those would report
|
|
834
999
|
// a hole where there is none.
|
|
@@ -909,6 +1074,11 @@ export class Subscription {
|
|
|
909
1074
|
// tree, under an arming the caller is already waiting on.
|
|
910
1075
|
if (this.#renominate) {
|
|
911
1076
|
this.#renominate = false;
|
|
1077
|
+
// Recovery often closes a fallback or successful probe immediately
|
|
1078
|
+
// after its greeting. It still proved the host reachable, so discard
|
|
1079
|
+
// the failure backoff accumulated before that greeting.
|
|
1080
|
+
if (reached)
|
|
1081
|
+
backoff = BACKOFF_MS;
|
|
912
1082
|
continue;
|
|
913
1083
|
}
|
|
914
1084
|
// A connection that got as far as its opening frame is not a failure,
|
|
@@ -966,22 +1136,55 @@ export class Subscription {
|
|
|
966
1136
|
throw err;
|
|
967
1137
|
}
|
|
968
1138
|
const status = c.status ?? 'unknown';
|
|
969
|
-
|
|
1139
|
+
// SETTLING IS FOREVER, so each of these has to be a state nobody is already
|
|
1140
|
+
// moving out of. `status` cannot say that on its own: it is read from the
|
|
1141
|
+
// guest process, and a start that has been ADMITTED has no process yet, so
|
|
1142
|
+
// it reads `stopped` through a cold boot and `suspended` through a resume —
|
|
1143
|
+
// whose session record is spent only on the way out of a start that worked.
|
|
1144
|
+
// A stream opened against a computer mid-resume used to die permanently and
|
|
1145
|
+
// name the resume as the reason (OPL-4631). nothingAdmitted is the
|
|
1146
|
+
// platform's own word for idle, and absence of the field is not that word.
|
|
1147
|
+
if (status === 'suspended' && nothingAdmitted(c)) {
|
|
970
1148
|
throw new SettledError(`${this.computerId} suspended, and the event stream is the one part of this API that does ` +
|
|
971
1149
|
'not resume a computer for you. Listening is not using, so a computer nobody touches ' +
|
|
972
1150
|
'suspends underneath its own stream. start_computer, then ask again.');
|
|
973
1151
|
}
|
|
974
|
-
|
|
975
|
-
|
|
1152
|
+
// Two terminal answers, and they are not the same answer. A disk that was
|
|
1153
|
+
// never finished is not started by anybody — the platform's own remedy is
|
|
1154
|
+
// "delete it and build it again" — so telling that caller to start_computer
|
|
1155
|
+
// sends them at a call that refuses them (Codex review).
|
|
1156
|
+
if (status === 'build-failed') {
|
|
1157
|
+
throw new SettledError(`${this.computerId}'s disk was never finished, so there is nothing to stream and nothing ` +
|
|
1158
|
+
'to start. Delete it and build it again.');
|
|
1159
|
+
}
|
|
1160
|
+
if (status === 'stopped' && nothingAdmitted(c)) {
|
|
1161
|
+
throw new SettledError(`${this.computerId} is stopped, and only a running computer has an event stream. ` +
|
|
976
1162
|
'start_computer, then ask again.');
|
|
977
1163
|
}
|
|
1164
|
+
// A computer whose disk was unlinked by a deletion that stopped partway
|
|
1165
|
+
// will never start again — the reference says so, and says deleting it
|
|
1166
|
+
// again is what clears it — so it belongs with the terminal states rather
|
|
1167
|
+
// than in the backoff below (Codex review).
|
|
1168
|
+
if (status === 'half-removed') {
|
|
1169
|
+
throw new SettledError(`${this.computerId} is half-removed: its disk is gone, it will never start again, and ` +
|
|
1170
|
+
'only deleting it again clears it. There is nothing to stream.');
|
|
1171
|
+
}
|
|
978
1172
|
if (status !== 'running') {
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
1173
|
+
// What is left is a state that MAY clear, which is a weaker claim than
|
|
1174
|
+
// the one this comment used to make and the honest one: `building`; a
|
|
1175
|
+
// `stopped` or `suspended` computer whose start has been admitted and is
|
|
1176
|
+
// loading; and the two readings that are not answers at all — a status
|
|
1177
|
+
// this client does not know, and a stopped or suspended computer whose
|
|
1178
|
+
// pool the host did not report. Retrying those is right for the same
|
|
1179
|
+
// reason waiting is: nothing here has said the machine will not come up.
|
|
1180
|
+
// What it is not is a promise that one will.
|
|
1181
|
+
//
|
|
1182
|
+
// That last pair is what this branch was FOR, and it could not reach it.
|
|
1183
|
+
// The comment here used to name `starting`, `moving` and `creating`; the
|
|
1184
|
+
// platform reports none of them — its statuses are `running`, `stopped`,
|
|
1185
|
+
// `suspended`, `building`, `build-failed` and `half-removed` — so the
|
|
1186
|
+
// flow it described, a create_computer followed at once by
|
|
1187
|
+
// wait_for_event("computer.ready"), met `stopped` and settled above.
|
|
985
1188
|
throw new MandalaError(`${this.computerId} is ${status}; waiting for it to be running`);
|
|
986
1189
|
}
|
|
987
1190
|
const vnc = c.vnc;
|
|
@@ -1073,12 +1276,17 @@ export class Subscription {
|
|
|
1073
1276
|
// `watching` is measured against, and what says whether a nomination
|
|
1074
1277
|
// made a moment ago is on this connection or on the next one.
|
|
1075
1278
|
//
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1279
|
+
// Proven refusals stay off the wire. During diagnosis, the last working
|
|
1280
|
+
// set is sent alone or with exactly one candidate; all nominations stay
|
|
1281
|
+
// in `#watches`, because an experiment is not an eviction.
|
|
1282
|
+
const recovery = this.#watchRecovery;
|
|
1283
|
+
const included = recovery
|
|
1284
|
+
? new Set([
|
|
1285
|
+
...recovery.baseline,
|
|
1286
|
+
...(recovery.phase === 'probe' && recovery.candidate ? [recovery.candidate] : []),
|
|
1287
|
+
])
|
|
1288
|
+
: undefined;
|
|
1289
|
+
this.#sent = this.#watches.filter((w) => !this.#watchRefused.has(w) && (!included || included.has(w)));
|
|
1082
1290
|
for (const w of this.#sent)
|
|
1083
1291
|
target.searchParams.append('watch', w);
|
|
1084
1292
|
socket = this.#socketFor(target.toString());
|
|
@@ -1100,6 +1308,8 @@ export class Subscription {
|
|
|
1100
1308
|
// A connection that has ended is not carrying anything, whatever it was
|
|
1101
1309
|
// greeted with. Without this the window between one connection ending
|
|
1102
1310
|
// and the next opening reads as a tree still on the wire.
|
|
1311
|
+
if (opened)
|
|
1312
|
+
this.#connectionVersion++;
|
|
1103
1313
|
this.#greeted = false;
|
|
1104
1314
|
if (this.#socket === socket)
|
|
1105
1315
|
this.#socket = undefined;
|
|
@@ -1166,6 +1376,15 @@ export class Subscription {
|
|
|
1166
1376
|
windows: Array.isArray(frame.windows) ? frame.windows : undefined,
|
|
1167
1377
|
watching: watched(frame.watching),
|
|
1168
1378
|
};
|
|
1379
|
+
const buffered = this.#ring.find((b) => b.event.cursor === hello.cursor);
|
|
1380
|
+
if (buffered)
|
|
1381
|
+
this.#helloFrom = buffered.index + 1;
|
|
1382
|
+
else if (hello.cursor === this.#beforeOldestCursor)
|
|
1383
|
+
this.#helloFrom = this.#oldest;
|
|
1384
|
+
else if (hello.cursor !== this.#hello?.cursor)
|
|
1385
|
+
this.#helloFrom = this.#nextIndex;
|
|
1386
|
+
// The same greeting on a reconnect must retain its original frontier,
|
|
1387
|
+
// including any history that has since been evicted.
|
|
1169
1388
|
this.#hello = hello;
|
|
1170
1389
|
this.#types = hello.events;
|
|
1171
1390
|
this.#greeted = true;
|
|
@@ -1173,7 +1392,7 @@ export class Subscription {
|
|
|
1173
1392
|
// could be observed from otherwise — and a connection that WORKS does not
|
|
1174
1393
|
// end for minutes or hours. An experiment whose positive result was only
|
|
1175
1394
|
// read on the way out is an experiment with no result.
|
|
1176
|
-
this.#cleared();
|
|
1395
|
+
const continueWatchRecovery = this.#cleared();
|
|
1177
1396
|
this.#adoptWatching(hello.watching);
|
|
1178
1397
|
this.#resume ??= hello.cursor || undefined;
|
|
1179
1398
|
this.#start ??= hello.cursor || undefined;
|
|
@@ -1194,6 +1413,12 @@ export class Subscription {
|
|
|
1194
1413
|
// that is a new session. One extra readiness is the cheaper wrong answer.
|
|
1195
1414
|
if (hello.ready && !resuming)
|
|
1196
1415
|
this.#pushReady(hello.cursor);
|
|
1416
|
+
// Advance only after this greeting has been fully adopted. Closing in
|
|
1417
|
+
// `#cleared` would let its remaining work set `open` and armed state after
|
|
1418
|
+
// the socket had already finished, briefly making an intermediate probe
|
|
1419
|
+
// look like the connection about to replace it.
|
|
1420
|
+
if (continueWatchRecovery)
|
|
1421
|
+
this.#reopen();
|
|
1197
1422
|
this.#wakeAll();
|
|
1198
1423
|
}
|
|
1199
1424
|
/**
|
|
@@ -1241,6 +1466,11 @@ export class Subscription {
|
|
|
1241
1466
|
}
|
|
1242
1467
|
sent.forEach((nominated, i) => {
|
|
1243
1468
|
const w = watching[i];
|
|
1469
|
+
// Keep the original index: `watching[i]` answers `sent[i]`, even when an
|
|
1470
|
+
// earlier nomination was evicted while this connection was closing.
|
|
1471
|
+
// The evicted entry itself must write nothing back into active metadata.
|
|
1472
|
+
if (!this.#watches.includes(nominated))
|
|
1473
|
+
return;
|
|
1244
1474
|
if (w.path !== nominated)
|
|
1245
1475
|
this.#hostName.set(nominated, w.path);
|
|
1246
1476
|
armed.set(nominated, w.armed);
|
|
@@ -1258,7 +1488,7 @@ export class Subscription {
|
|
|
1258
1488
|
// would drop a permanent condition that nothing puts back. `hello.watching`
|
|
1259
1489
|
// has no field for it, so the flag is the only record there is.
|
|
1260
1490
|
if (w.armed && !this.isArmed(nominated)) {
|
|
1261
|
-
this.#
|
|
1491
|
+
this.#armedTransition(nominated);
|
|
1262
1492
|
this.#watchLost.delete(nominated);
|
|
1263
1493
|
}
|
|
1264
1494
|
else if (this.#watchLost.get(nominated) === 'unwatchable') {
|
|
@@ -1285,53 +1515,117 @@ export class Subscription {
|
|
|
1285
1515
|
* reconnecting forever with nothing ever saying why.
|
|
1286
1516
|
*
|
|
1287
1517
|
* But a host that is down fails in exactly the same way, so two missed
|
|
1288
|
-
* handshakes are not proof of anything.
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1518
|
+
* handshakes are not proof of anything. Recovery first returns to the last
|
|
1519
|
+
* set that opened. If that fallback opens, each new nomination is added back
|
|
1520
|
+
* alone. A candidate that fails is withheld once more and blamed only when
|
|
1521
|
+
* the known-good set opens again. If that set also fails, recovery keeps it
|
|
1522
|
+
* as the reconnect target and retests the candidate after the host returns.
|
|
1293
1523
|
*/
|
|
1294
1524
|
#cleared() {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
this.#
|
|
1299
|
-
|
|
1525
|
+
const recovery = this.#watchRecovery;
|
|
1526
|
+
if (!recovery) {
|
|
1527
|
+
this.#lastGood = [...this.#sent];
|
|
1528
|
+
this.#upgradeFailures = 0;
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
// A `fallback` that opens proves only that the host is up and that the last
|
|
1532
|
+
// working set still works. It is deliberately NOT a branch here, not even
|
|
1533
|
+
// for a single addition: the full set failed twice, and a host that was down
|
|
1534
|
+
// for those two attempts fails exactly as a refused nomination does, so the
|
|
1535
|
+
// one thing the fallback opening cannot tell us is which of the two it was.
|
|
1536
|
+
// Filing the only pending tree as refused from here read an outage as a
|
|
1537
|
+
// refusal — the addition fell out of the watch set, and the model was told
|
|
1538
|
+
// this host would not carry a tree it would have carried. The probe below
|
|
1539
|
+
// costs one round trip and answers the question instead of guessing at it.
|
|
1540
|
+
if (recovery.phase === 'recover') {
|
|
1541
|
+
// A fallback failed too, so the outage made the preceding evidence
|
|
1542
|
+
// ambiguous. Now that the known-good set is back, test that candidate
|
|
1543
|
+
// again instead of filing a refusal from before the outage.
|
|
1544
|
+
recovery.candidate ??= recovery.pending.shift();
|
|
1545
|
+
if (recovery.candidate !== undefined) {
|
|
1546
|
+
recovery.phase = 'probe';
|
|
1547
|
+
this.#lastGood = [...recovery.baseline];
|
|
1548
|
+
this.#upgradeFailures = 0;
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
else if (recovery.phase === 'probe' && recovery.candidate !== undefined) {
|
|
1553
|
+
// This candidate opened beside the known-good set, so retain it while the
|
|
1554
|
+
// remaining additions are tested.
|
|
1555
|
+
recovery.baseline.push(recovery.candidate);
|
|
1556
|
+
}
|
|
1557
|
+
else if (recovery.phase === 'confirm' && recovery.candidate !== undefined) {
|
|
1558
|
+
// The candidate failed beside this set and the same set has now reopened
|
|
1559
|
+
// without it. Only here is a refusal proven.
|
|
1560
|
+
this.#watchRefused.add(recovery.candidate);
|
|
1300
1561
|
}
|
|
1301
|
-
|
|
1562
|
+
recovery.candidate = undefined;
|
|
1563
|
+
const next = recovery.pending.shift();
|
|
1564
|
+
if (next !== undefined) {
|
|
1565
|
+
recovery.candidate = next;
|
|
1566
|
+
recovery.phase = 'probe';
|
|
1567
|
+
this.#lastGood = [...recovery.baseline];
|
|
1568
|
+
this.#upgradeFailures = 0;
|
|
1569
|
+
return true;
|
|
1570
|
+
}
|
|
1571
|
+
this.#lastGood = [...recovery.baseline];
|
|
1572
|
+
this.#watchRecovery = undefined;
|
|
1302
1573
|
this.#upgradeFailures = 0;
|
|
1303
|
-
|
|
1574
|
+
return false;
|
|
1304
1575
|
}
|
|
1305
1576
|
/** @see {@link #cleared} — the other half, for a connection that never greeted. */
|
|
1306
1577
|
#blamed() {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
|
|
1312
|
-
this.#
|
|
1313
|
-
this.#
|
|
1578
|
+
const recovery = this.#watchRecovery;
|
|
1579
|
+
if (recovery?.phase === 'probe') {
|
|
1580
|
+
// A failed probe alone is not proof: the host may have gone down after
|
|
1581
|
+
// the fallback opened. Retry the known-good set before blaming the tree.
|
|
1582
|
+
recovery.phase = 'confirm';
|
|
1583
|
+
this.#upgradeFailures = 0;
|
|
1584
|
+
this.#wakeAll();
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (recovery) {
|
|
1588
|
+
// The known-good set failed too, so this is a host outage rather than
|
|
1589
|
+
// evidence against a watch. Keep reconnecting that set; once it opens,
|
|
1590
|
+
// the ambiguous candidate is probed again from fresh evidence.
|
|
1591
|
+
recovery.phase = 'recover';
|
|
1314
1592
|
this.#upgradeFailures = 0;
|
|
1315
1593
|
this.#wakeAll();
|
|
1316
1594
|
return;
|
|
1317
1595
|
}
|
|
1318
|
-
if (!this.#sent.length
|
|
1596
|
+
if (!this.#sent.length)
|
|
1319
1597
|
return;
|
|
1320
1598
|
if (++this.#upgradeFailures < WATCH_SHED_AFTER)
|
|
1321
1599
|
return;
|
|
1322
1600
|
this.#upgradeFailures = 0;
|
|
1323
|
-
// Whatever changed since this stream last worked, and only then the newest.
|
|
1324
|
-
// After an LRU refresh "newest" means "most recently asked about", which is
|
|
1325
|
-
// the opposite of a good suspect: the tree a caller keeps asking about is
|
|
1326
|
-
// the one least likely to be new. What the last connection that actually
|
|
1327
|
-
// greeted was carrying is the real before-and-after.
|
|
1328
1601
|
const carried = this.#sent;
|
|
1329
|
-
this.#
|
|
1330
|
-
|
|
1602
|
+
const baseline = this.#lastGood.filter((watch) => carried.includes(watch) && !this.#watchRefused.has(watch));
|
|
1603
|
+
let pending = carried.filter((watch) => !baseline.includes(watch));
|
|
1604
|
+
// If an unchanged set stops opening, retain the old single-watch experiment:
|
|
1605
|
+
// remove one tree and see whether the rest still opens. This can detect a
|
|
1606
|
+
// watch the host stopped accepting without reading an outage as a refusal.
|
|
1607
|
+
if (!pending.length) {
|
|
1608
|
+
const candidate = carried[carried.length - 1];
|
|
1609
|
+
pending = [candidate];
|
|
1610
|
+
baseline.splice(baseline.indexOf(candidate), 1);
|
|
1611
|
+
}
|
|
1612
|
+
this.#watchRecovery = { baseline, pending, phase: 'fallback' };
|
|
1331
1613
|
this.#wakeAll();
|
|
1332
1614
|
}
|
|
1333
1615
|
#bumpArm(path) {
|
|
1334
|
-
|
|
1616
|
+
if (!this.#watches.includes(path))
|
|
1617
|
+
return;
|
|
1618
|
+
this.#armGen.set(path, ++this.#nextArmGen);
|
|
1619
|
+
}
|
|
1620
|
+
/** Record an arm, retaining later arms until a file wait explains their gap. */
|
|
1621
|
+
#armedTransition(path) {
|
|
1622
|
+
if (!this.#watches.includes(path))
|
|
1623
|
+
return;
|
|
1624
|
+
if (this.#everArmed.has(path))
|
|
1625
|
+
this.#undisclosedRearm.add(path);
|
|
1626
|
+
else
|
|
1627
|
+
this.#everArmed.add(path);
|
|
1628
|
+
this.#bumpArm(path);
|
|
1335
1629
|
}
|
|
1336
1630
|
/**
|
|
1337
1631
|
* What a `file.changed` says about the TREE, as opposed to about a file.
|
|
@@ -1375,7 +1669,7 @@ export class Subscription {
|
|
|
1375
1669
|
// clears the flag when the link goes down — and the wait that should have
|
|
1376
1670
|
// said "re-read the tree" would have gone on waiting on a tree whose
|
|
1377
1671
|
// history had a hole in it.
|
|
1378
|
-
this.#
|
|
1672
|
+
this.#armedTransition(watch);
|
|
1379
1673
|
this.#armed.set(watch, true);
|
|
1380
1674
|
return;
|
|
1381
1675
|
}
|
|
@@ -1535,11 +1829,25 @@ export class EventHub {
|
|
|
1535
1829
|
* resume — reaped for idleness, stopped on a computer somebody suspended —
|
|
1536
1830
|
* and one there is nothing left to resume: a deleted computer's cursor names
|
|
1537
1831
|
* a position in a stream that no longer exists.
|
|
1832
|
+
*
|
|
1833
|
+
* `expect` is the subscription the caller drained, and dropping BY IDENTITY
|
|
1834
|
+
* rather than by id is what makes this safe under the concurrency these tools
|
|
1835
|
+
* are designed for. Two calls on one computer overlap by design — a
|
|
1836
|
+
* `wait_for_event` parked while a `poll_events` runs — and each holds its own
|
|
1837
|
+
* handle. Without the check: A finds its stream stopped, drains it and drops
|
|
1838
|
+
* it; the model's next call opens a fresh, healthy S2; B, still holding the
|
|
1839
|
+
* stale S1, wakes, sees the status it recorded, drains an empty ring and
|
|
1840
|
+
* drops — closing S2, a stream nothing was wrong with, out from under whoever
|
|
1841
|
+
* had just opened it. Looked up by id, `drop` had no way to tell the two
|
|
1842
|
+
* apart. A caller with no handle to name (the idle sweep passes the entry it
|
|
1843
|
+
* is iterating) still drops whatever is there.
|
|
1538
1844
|
*/
|
|
1539
|
-
drop(computerId, reason, remember = false) {
|
|
1845
|
+
drop(computerId, reason, remember = false, expect) {
|
|
1540
1846
|
const sub = this.#subs.get(computerId);
|
|
1541
1847
|
if (!sub)
|
|
1542
1848
|
return;
|
|
1849
|
+
if (expect !== undefined && sub !== expect)
|
|
1850
|
+
return;
|
|
1543
1851
|
const at = sub.resumeCursor;
|
|
1544
1852
|
const watches = sub.nominations;
|
|
1545
1853
|
sub.close(reason);
|
|
@@ -1580,7 +1888,7 @@ export class EventHub {
|
|
|
1580
1888
|
// be reported once. The idle window is what eventually takes it.
|
|
1581
1889
|
if (sub.idleMs < IDLE_REAP_MS)
|
|
1582
1890
|
continue;
|
|
1583
|
-
this.drop(id, 'nothing asked about this computer for five minutes', true);
|
|
1891
|
+
this.drop(id, 'nothing asked about this computer for five minutes', true, sub);
|
|
1584
1892
|
}
|
|
1585
1893
|
if (!this.#subs.size)
|
|
1586
1894
|
this.#stopSweep();
|