mandala-computer-mcp 0.1.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/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- package/package.json +59 -0
package/dist/events.js
ADDED
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a computer is doing, held open on this side of the model.
|
|
3
|
+
*
|
|
4
|
+
* `GET computers/:id/events` (platform OPL-3785) is a websocket that says what
|
|
5
|
+
* a computer is doing without being asked, so that an agent stops paying for a
|
|
6
|
+
* screenshot to learn that nothing has changed. Every other client of that
|
|
7
|
+
* stream hands it to its caller as an iterator, because every other caller sits
|
|
8
|
+
* in a loop. A model does not: it takes turns, and between two turns there is
|
|
9
|
+
* nobody here to read a socket.
|
|
10
|
+
*
|
|
11
|
+
* So the socket lives HERE, in the session, and the model holds nothing but a
|
|
12
|
+
* cursor. One connection per computer, opened the first time a tool asks about
|
|
13
|
+
* it, kept across turns, reaped when nothing has asked for a while. What
|
|
14
|
+
* arrives while the model is thinking goes into a bounded ring, and the next
|
|
15
|
+
* `poll_events` or `wait_for_event` is handed it in order. The model never
|
|
16
|
+
* learns that a socket exists.
|
|
17
|
+
*
|
|
18
|
+
* That is the whole of OPL-3926's decision, and the alternative it rejects is
|
|
19
|
+
* worth naming: an MCP `resources/subscribe` and a `notifications/*` doorbell.
|
|
20
|
+
* The spec's notification filter is a closed vocabulary — `toolsListChanged`,
|
|
21
|
+
* `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions` — with
|
|
22
|
+
* no channel for a server's own domain events, so a `window.opened` could only
|
|
23
|
+
* ride in as `resources/updated` on some URI, and that notification carries a
|
|
24
|
+
* URI and no payload. The model would still have to read. Which makes the
|
|
25
|
+
* doorbell an addition to this file rather than a replacement for it, and one
|
|
26
|
+
* whose bell nothing on the other end rings yet.
|
|
27
|
+
*
|
|
28
|
+
* Written against the `events_url` entry in the platform's `web/lib/apidoc.ts`,
|
|
29
|
+
* which is the reference this must not contradict.
|
|
30
|
+
*/
|
|
31
|
+
import { posix } from 'node:path';
|
|
32
|
+
import { WebSocket as UndiciWebSocket } from 'undici';
|
|
33
|
+
import { isTransientForPoll, MandalaError } from './errors.js';
|
|
34
|
+
import { unwrapComputer } from './format.js';
|
|
35
|
+
import * as P from './paths.js';
|
|
36
|
+
/**
|
|
37
|
+
* undici's `WebSocket`, not Node's global one.
|
|
38
|
+
*
|
|
39
|
+
* The same choice `api.ts` makes about `fetch` and for a plainer reason: this
|
|
40
|
+
* package already depends on undici, `engines` says Node 20.3 and the global
|
|
41
|
+
* `WebSocket` did not arrive unflagged until Node 22. A server that worked on
|
|
42
|
+
* the Node it claims to support for every tool except these two would be a
|
|
43
|
+
* worse failure than not shipping them, because it only appears at the moment
|
|
44
|
+
* somebody waits for something.
|
|
45
|
+
*/
|
|
46
|
+
export const defaultEventSocket = (url) => new UndiciWebSocket(url);
|
|
47
|
+
/**
|
|
48
|
+
* The frames that are statements about the STREAM rather than about the
|
|
49
|
+
* computer.
|
|
50
|
+
*
|
|
51
|
+
* None of the three is delivered as an event here, which is the second half of
|
|
52
|
+
* OPL-3926's decision. A model handed a `gap` frame — a type it has no
|
|
53
|
+
* procedure for, on a client the reference cannot tell to "reconcile with a
|
|
54
|
+
* listing" — will invent a recovery procedure. So a gap is answered inline
|
|
55
|
+
* instead: see {@link Subscription.loss} and the state the tools attach to it.
|
|
56
|
+
*/
|
|
57
|
+
const STREAM_FRAMES = new Set(['gap', 'closed', 'capabilities']);
|
|
58
|
+
/**
|
|
59
|
+
* How many trees ONE stream may nominate, which is the platform's own cap.
|
|
60
|
+
*
|
|
61
|
+
* There is a second, larger one this client cannot enforce: a computer watches
|
|
62
|
+
* at most 32 distinct trees across every stream open on it, and a nomination
|
|
63
|
+
* past that is refused on the upgrade — which reaches a websocket client as a
|
|
64
|
+
* socket that would not open and nothing else. See {@link Subscription.nominate}
|
|
65
|
+
* for what is done about that.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_WATCHES = 4;
|
|
68
|
+
/**
|
|
69
|
+
* How many connections in a row may fail to open before a watch is blamed.
|
|
70
|
+
*
|
|
71
|
+
* A watch is the one thing on this URL a HOST can refuse: a path it will not
|
|
72
|
+
* honour is a `400` on the upgrade, and a nomination past the 32 trees a
|
|
73
|
+
* computer will watch is a `409`. Neither status nor body reaches a websocket
|
|
74
|
+
* client — undici reports both as "Received network error or non-101 status
|
|
75
|
+
* code" — so a refused watch is indistinguishable here from a host that is
|
|
76
|
+
* down, and the reconnect loop would ask for the same refused set forever.
|
|
77
|
+
*
|
|
78
|
+
* That is not a file watch failing. It is the WHOLE STREAM failing: the window
|
|
79
|
+
* events, the process exits and the readiness that were arriving before anybody
|
|
80
|
+
* asked about a directory all stop, and nothing ever says why. So after this
|
|
81
|
+
* many failures the newest nomination is shed and the stream is allowed back —
|
|
82
|
+
* newest because it is the one that has just changed, and the older ones were
|
|
83
|
+
* connecting a moment ago. Two rather than one, because a single failed
|
|
84
|
+
* connection is ordinary weather.
|
|
85
|
+
*/
|
|
86
|
+
const WATCH_SHED_AFTER = 2;
|
|
87
|
+
/** The platform's bound on one nominated path, in BYTES rather than characters. */
|
|
88
|
+
export const MAX_WATCH_PATH_BYTES = 256;
|
|
89
|
+
/**
|
|
90
|
+
* A nominated path, in the spelling the host will accept — or a refusal saying
|
|
91
|
+
* why it will not.
|
|
92
|
+
*
|
|
93
|
+
* A mirror of the platform's `cleanWatchPath`, and mirrored rather than left to
|
|
94
|
+
* the server for one reason: a path the host refuses is a `400` on the UPGRADE,
|
|
95
|
+
* and an upgrade that fails reaches a websocket client as an error event with
|
|
96
|
+
* no status and no body. Undici says "Received network error or non-101 status
|
|
97
|
+
* code" and that is the whole of it. So a bad path sent optimistically is
|
|
98
|
+
* indistinguishable here from a host that is down, and the answer a model would
|
|
99
|
+
* get is a reconnect loop under "could not open the event stream" — for a
|
|
100
|
+
* mistake that is entirely visible before anything is sent.
|
|
101
|
+
*
|
|
102
|
+
* Normalising rather than refusing the shapes people actually type is the
|
|
103
|
+
* platform's choice and is kept: a trailing slash and a `.` segment name one
|
|
104
|
+
* directory unambiguously. What must not happen is normalising SILENTLY, since
|
|
105
|
+
* the cleaned form is what events carry and what a caller has to match on — so
|
|
106
|
+
* the tools say what a path became when it changed.
|
|
107
|
+
*/
|
|
108
|
+
export function cleanWatchPath(input) {
|
|
109
|
+
if (!input)
|
|
110
|
+
throw new MandalaError('a watch path cannot be empty');
|
|
111
|
+
if (Buffer.byteLength(input, 'utf8') > MAX_WATCH_PATH_BYTES) {
|
|
112
|
+
throw new MandalaError(`a watch path may be at most ${MAX_WATCH_PATH_BYTES} bytes; that one is ` +
|
|
113
|
+
`${Buffer.byteLength(input, 'utf8')}`);
|
|
114
|
+
}
|
|
115
|
+
// A lone surrogate is a string JavaScript will hold and UTF-8 cannot carry.
|
|
116
|
+
// The round trip replaces one with U+FFFD, which is the cheapest way to ask
|
|
117
|
+
// "would this survive being sent" without hand-decoding the code units.
|
|
118
|
+
if (Buffer.from(input, 'utf8').toString('utf8') !== input) {
|
|
119
|
+
throw new MandalaError('a watch path must be valid UTF-8');
|
|
120
|
+
}
|
|
121
|
+
// Refused rather than escaped, as the platform refuses them: the value ends
|
|
122
|
+
// up in log lines and in an opening frame, so a newline in one is a caller
|
|
123
|
+
// choosing what somebody else's terminal renders.
|
|
124
|
+
for (const ch of input) {
|
|
125
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
126
|
+
if (code < 0x20 || code === 0x7f) {
|
|
127
|
+
throw new MandalaError('a watch path cannot contain control characters');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!input.startsWith('/')) {
|
|
131
|
+
throw new MandalaError(`a watch path must be absolute, and ${JSON.stringify(input)} is not. Paths on this stream ` +
|
|
132
|
+
"are the guest's own, so there is no working directory here for a relative one to be " +
|
|
133
|
+
'relative to.');
|
|
134
|
+
}
|
|
135
|
+
// Go's `path.Clean` and not Node's `normalize`, because what has to come out
|
|
136
|
+
// of here is the spelling the HOST will echo, and the two do not agree
|
|
137
|
+
// everywhere. Both differences are fixed up rather than relied on: normalize
|
|
138
|
+
// keeps a trailing slash and Clean does not, and a leading `//` is a shape
|
|
139
|
+
// POSIX leaves implementation-defined — Node collapses it on the versions
|
|
140
|
+
// this was measured on, but `engines` here says 20.3 and up and this is not
|
|
141
|
+
// something to discover from a `400` on an upgrade that reaches a websocket
|
|
142
|
+
// client with no status and no body. Clean collapses it; so does this.
|
|
143
|
+
let c = posix.normalize(input);
|
|
144
|
+
if (c.length > 1 && c.endsWith('/'))
|
|
145
|
+
c = c.slice(0, -1);
|
|
146
|
+
c = c.replace(/^\/{2,}/, '/');
|
|
147
|
+
if (c === '/') {
|
|
148
|
+
throw new MandalaError('watching / is not a nomination; name the directory you are waiting on. The root is every ' +
|
|
149
|
+
'tree at once, which would spend the directory budget on /usr before reaching anything ' +
|
|
150
|
+
'you care about and then report nothing but loss.');
|
|
151
|
+
}
|
|
152
|
+
return c;
|
|
153
|
+
}
|
|
154
|
+
/** How many events one computer may hold for a model that has not read them. */
|
|
155
|
+
export const MAX_BUFFERED = 1024;
|
|
156
|
+
/** How long a subscription nothing has asked about is kept open. */
|
|
157
|
+
export const IDLE_REAP_MS = 5 * 60_000;
|
|
158
|
+
/** How often the hub looks for one to reap. */
|
|
159
|
+
const SWEEP_MS = 60_000;
|
|
160
|
+
/** First backoff step after a failed connection, doubling to {@link MAX_BACKOFF_MS}. */
|
|
161
|
+
const BACKOFF_MS = 500;
|
|
162
|
+
const MAX_BACKOFF_MS = 15_000;
|
|
163
|
+
/** How long a connection has to reach its opening frame. */
|
|
164
|
+
const CONNECT_TIMEOUT_MS = 20_000;
|
|
165
|
+
/**
|
|
166
|
+
* One computer's event stream, held across turns.
|
|
167
|
+
*
|
|
168
|
+
* Single-consumer, like the session it belongs to. It keeps its own place in
|
|
169
|
+
* the platform's stream so that a socket which drops mid-turn resumes rather
|
|
170
|
+
* than restarts, and it keeps the model's place in the buffer so that a model
|
|
171
|
+
* which asks nothing for four turns is still handed what happened during them.
|
|
172
|
+
*/
|
|
173
|
+
export class Subscription {
|
|
174
|
+
computerId;
|
|
175
|
+
#api;
|
|
176
|
+
#socketFor;
|
|
177
|
+
#ring = [];
|
|
178
|
+
/** The index the next arriving event will be given. Never reused. */
|
|
179
|
+
#nextIndex = 0;
|
|
180
|
+
/** The index after the last event handed to the model. */
|
|
181
|
+
#delivered = 0;
|
|
182
|
+
/** Set once the model has read anything at all, so a first read can say so. */
|
|
183
|
+
#read = false;
|
|
184
|
+
#loss;
|
|
185
|
+
/** The platform cursor to resume from: after the last event RECEIVED. */
|
|
186
|
+
#resume;
|
|
187
|
+
/**
|
|
188
|
+
* Where this stream began, as a platform cursor.
|
|
189
|
+
*
|
|
190
|
+
* The seeded `since`, or the first opening frame's own cursor. It is what a
|
|
191
|
+
* NEW subscription for this computer has to resume from when the model was
|
|
192
|
+
* never handed anything, so that a buffer thrown away by a reap is replayed
|
|
193
|
+
* rather than skipped.
|
|
194
|
+
*/
|
|
195
|
+
#start;
|
|
196
|
+
/** The cursor after the last event actually handed to the model. */
|
|
197
|
+
#deliveredCursor;
|
|
198
|
+
#hello;
|
|
199
|
+
#types;
|
|
200
|
+
/**
|
|
201
|
+
* The trees nominated on this stream, oldest nomination first.
|
|
202
|
+
*
|
|
203
|
+
* Held here rather than on the socket because it OUTLIVES the socket: it is
|
|
204
|
+
* what every reconnect re-nominates, and a set that lived on the connection
|
|
205
|
+
* would be silently dropped by the first reconnect — leaving a model waiting
|
|
206
|
+
* on a tree nobody was watching any more, which is the one failure this whole
|
|
207
|
+
* feature is built to make impossible.
|
|
208
|
+
*/
|
|
209
|
+
#watches = [];
|
|
210
|
+
/**
|
|
211
|
+
* What the host calls each nominated tree, when that is not what we called it.
|
|
212
|
+
*
|
|
213
|
+
* The reference says to match on what `hello` gives back rather than on what
|
|
214
|
+
* you sent, and this is that — kept as a MAP rather than by overwriting the
|
|
215
|
+
* nominations, which is the shape that reads naturally and is wrong. The
|
|
216
|
+
* nominations are what goes back on the URL at every reconnect, so replacing
|
|
217
|
+
* them with the host's spelling makes this client's idea of a tree drift one
|
|
218
|
+
* rename per connection; and if the two normalisations ever disagreed about a
|
|
219
|
+
* path, every call naming it would see a tree it had not nominated, reopen
|
|
220
|
+
* the socket, and be renamed again. One end has to be fixed, and it is the
|
|
221
|
+
* end that does the sending.
|
|
222
|
+
*
|
|
223
|
+
* Filled by position, which is exactly what the platform promises: it
|
|
224
|
+
* de-duplicates and preserves order, and this client never nominates a
|
|
225
|
+
* duplicate, so entry i of the echo is nomination i.
|
|
226
|
+
*/
|
|
227
|
+
#hostName = new Map();
|
|
228
|
+
/** Whether each nominated tree is being watched YET. See {@link Watched}. */
|
|
229
|
+
#armed = new Map();
|
|
230
|
+
/**
|
|
231
|
+
* How many times each tree has come up, so a RE-arm can be told from the one
|
|
232
|
+
* a caller waited for.
|
|
233
|
+
*
|
|
234
|
+
* A second `armed` is not a duplicate to suppress: it says the watch was
|
|
235
|
+
* interrupted and is reporting from here, so anything that happened in
|
|
236
|
+
* between was never reported and the tree has to be re-read. A wait that
|
|
237
|
+
* matched only on file paths would sit through that and then say nothing
|
|
238
|
+
* changed.
|
|
239
|
+
*/
|
|
240
|
+
#armGen = new Map();
|
|
241
|
+
/**
|
|
242
|
+
* The STANDING loss on each tree, cleared when it arms.
|
|
243
|
+
*
|
|
244
|
+
* Standing is the whole of what this holds, and it is why a `flood` is not in
|
|
245
|
+
* it. A flood is a burst: the tree changed faster than the cap allows it to be
|
|
246
|
+
* reported, and once the burst is over the tree is being reported normally
|
|
247
|
+
* again — so remembering one would make every later answer about a tree that
|
|
248
|
+
* saw one build hedge forever. `budget` and `unwatchable` are conditions
|
|
249
|
+
* rather than moments: part of the tree is not being watched, or none of it
|
|
250
|
+
* is, and both stay true until something changes them.
|
|
251
|
+
*/
|
|
252
|
+
#watchLost = new Map();
|
|
253
|
+
/**
|
|
254
|
+
* Trees this subscription inherited from one that went away.
|
|
255
|
+
*
|
|
256
|
+
* A tree is watched by the CONNECTION, so the reap that took the previous
|
|
257
|
+
* subscription also stopped the guest watching — and inotify reports changes
|
|
258
|
+
* and not state, so nothing that happened in between was recorded anywhere for
|
|
259
|
+
* a replay to hand back. Re-nominating gets the watch going again and says
|
|
260
|
+
* nothing about the hole, which would leave a model reading a perfectly
|
|
261
|
+
* ordinary "nothing changed" over minutes during which nothing was looking.
|
|
262
|
+
* Said once, on the first answer about the tree, and then forgotten.
|
|
263
|
+
*/
|
|
264
|
+
#interrupted = new Set();
|
|
265
|
+
/**
|
|
266
|
+
* Trees a connection carrying them would not open, PROVEN so.
|
|
267
|
+
*
|
|
268
|
+
* Proven means the experiment came back positive — see {@link #cleared}: the
|
|
269
|
+
* tree was withheld, the same stream opened without it, and there is nothing
|
|
270
|
+
* else the difference could be. A tree merely suspected is
|
|
271
|
+
* {@link #shedCandidate} and is not in here.
|
|
272
|
+
*/
|
|
273
|
+
#watchRefused = new Set();
|
|
274
|
+
/**
|
|
275
|
+
* The tree currently withheld from the URL to find out whether it is the
|
|
276
|
+
* reason nothing will open. `undefined` when no experiment is running.
|
|
277
|
+
*
|
|
278
|
+
* It stays in {@link #watches} throughout, which is what keeps a caller from
|
|
279
|
+
* being told its tree was evicted to make room for somebody else's — this is
|
|
280
|
+
* a suspicion, not a decision about what the caller asked for.
|
|
281
|
+
*/
|
|
282
|
+
#shedCandidate;
|
|
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;
|
|
292
|
+
/** What the last connection that reached an opening frame was carrying. */
|
|
293
|
+
#lastGood = [];
|
|
294
|
+
/** Consecutive connections that never reached an opening frame while watching. */
|
|
295
|
+
#upgradeFailures = 0;
|
|
296
|
+
/**
|
|
297
|
+
* Whether the host echoed `watching` for a nomination this stream made.
|
|
298
|
+
*
|
|
299
|
+
* `undefined` until an opening frame has been seen with something nominated.
|
|
300
|
+
* A host that predates `file.changed` ignores `&watch=` rather than refusing
|
|
301
|
+
* it, so the socket opens, nothing is watched, and no event ever arrives —
|
|
302
|
+
* a silence indistinguishable from a quiet directory. This is how that is
|
|
303
|
+
* told apart, and it is the only way: the frame is the whole of the answer.
|
|
304
|
+
*/
|
|
305
|
+
#watchingEchoed;
|
|
306
|
+
/**
|
|
307
|
+
* The trees the connection now open put on its URL, and whether it has
|
|
308
|
+
* greeted.
|
|
309
|
+
*
|
|
310
|
+
* Two fields for one question — "is the tree I just nominated actually on the
|
|
311
|
+
* wire" — and the question has to be asked that way rather than by counting
|
|
312
|
+
* opening frames. A nomination can land while a connection is IN FLIGHT: the
|
|
313
|
+
* socket dropped, the reconnect has already built its URL from the old watch
|
|
314
|
+
* set, and its `hello` is still coming. Counting frames, that frame answers
|
|
315
|
+
* the nomination — and it is a frame from a connection that never carried it,
|
|
316
|
+
* so its missing `watching` reads as a host that ignores watches and the
|
|
317
|
+
* caller is refused for a reason that is not true.
|
|
318
|
+
*/
|
|
319
|
+
#sent = [];
|
|
320
|
+
#greeted = false;
|
|
321
|
+
/**
|
|
322
|
+
* Whether the socket now closing was closed by THIS side to re-nominate.
|
|
323
|
+
*
|
|
324
|
+
* A watch set is a connection parameter, so adding one means opening a new
|
|
325
|
+
* connection. The reconnect floor in {@link #loop} exists to stop a host that
|
|
326
|
+
* sends `hello` and closes from becoming a spin loop; a reconnect this side
|
|
327
|
+
* asked for is not that, and paying half a second for one would be half a
|
|
328
|
+
* second added to every first watch on a tree.
|
|
329
|
+
*/
|
|
330
|
+
#renominate = false;
|
|
331
|
+
#state = { status: 'connecting' };
|
|
332
|
+
#wake = new Set();
|
|
333
|
+
#socket;
|
|
334
|
+
#abort = new AbortController();
|
|
335
|
+
#lastUsed = Date.now();
|
|
336
|
+
#running = false;
|
|
337
|
+
constructor(api, computerId, socketFor, since, watches = []) {
|
|
338
|
+
// Never `extra.signal`. A subscription outlives the tool call that opened
|
|
339
|
+
// it by design, and binding it to that call's signal would close the socket
|
|
340
|
+
// the moment the turn that opened it ended — which is every turn.
|
|
341
|
+
this.#api = api;
|
|
342
|
+
this.computerId = computerId;
|
|
343
|
+
this.#socketFor = socketFor;
|
|
344
|
+
// Where a previous subscription for this computer had got to, if the hub
|
|
345
|
+
// still remembers one. The first connection then asks the platform to
|
|
346
|
+
// replay from there rather than joining at the head — see
|
|
347
|
+
// {@link EventHub.open}, which is where that memory lives.
|
|
348
|
+
this.#resume = since;
|
|
349
|
+
this.#start = since;
|
|
350
|
+
// And what it was watching, for the same reason and with a sharper edge.
|
|
351
|
+
// A tree stops being watched when the socket carrying it closes, so a reap
|
|
352
|
+
// silently ends every watch on that computer — and the next answer about
|
|
353
|
+
// one would be an honest-looking "nothing changed" over a window during
|
|
354
|
+
// which nothing was watching, which is the one thing a file watch must
|
|
355
|
+
// never produce. Nominating them again on the first connection is what
|
|
356
|
+
// makes the tool's promise that a nomination lasts across turns true.
|
|
357
|
+
this.#watches = watches.slice(0, MAX_WATCHES);
|
|
358
|
+
for (const w of this.#watches)
|
|
359
|
+
this.#interrupted.add(w);
|
|
360
|
+
}
|
|
361
|
+
/** The trees this stream nominates, in this client's own spelling. */
|
|
362
|
+
get nominations() {
|
|
363
|
+
return [...this.#watches];
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Where a REPLACEMENT for this subscription should start.
|
|
367
|
+
*
|
|
368
|
+
* The position after the last event the MODEL was handed — deliberately not
|
|
369
|
+
* `#resume`, which is after the last event this SOCKET received. The two are
|
|
370
|
+
* the same only when the model is caught up, and the case where they differ
|
|
371
|
+
* is the case this exists for: events arrive, the model is busy on another
|
|
372
|
+
* computer, the idle sweep reaps the subscription and its ring goes with it.
|
|
373
|
+
* Handing the replacement `#resume` there would ask the platform to replay
|
|
374
|
+
* from AFTER the unread events — losing exactly what the memory was meant to
|
|
375
|
+
* preserve, and losing it silently, since a `since` the platform can honour
|
|
376
|
+
* produces no gap.
|
|
377
|
+
*
|
|
378
|
+
* Before anything has been delivered the answer is where this stream began,
|
|
379
|
+
* which replays the whole buffer.
|
|
380
|
+
*/
|
|
381
|
+
get resumeCursor() {
|
|
382
|
+
return this.#deliveredCursor ?? this.#start;
|
|
383
|
+
}
|
|
384
|
+
get state() {
|
|
385
|
+
return this.#state;
|
|
386
|
+
}
|
|
387
|
+
get idleMs() {
|
|
388
|
+
return Date.now() - this.#lastUsed;
|
|
389
|
+
}
|
|
390
|
+
/** What this computer can emit, as last stated. `undefined` before `hello`. */
|
|
391
|
+
get eventTypes() {
|
|
392
|
+
return this.#types ? [...this.#types] : undefined;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The trees nominated on this stream, and whether each is live yet.
|
|
396
|
+
*
|
|
397
|
+
* In the HOST's spelling, because that is the one every event carries and so
|
|
398
|
+
* the one a reader of this has to be able to match on.
|
|
399
|
+
*/
|
|
400
|
+
get watching() {
|
|
401
|
+
return this.#watches.map((path) => ({
|
|
402
|
+
path: this.hostPath(path),
|
|
403
|
+
armed: this.#armed.get(path) === true,
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
/** What the host calls a tree this stream nominated. Its own name until it says. */
|
|
407
|
+
hostPath(nominated) {
|
|
408
|
+
return this.#hostName.get(nominated) ?? nominated;
|
|
409
|
+
}
|
|
410
|
+
/** Whether this tree is being watched right now. */
|
|
411
|
+
isArmed(path) {
|
|
412
|
+
return this.#armed.get(path) === true;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Which arming of this tree is current.
|
|
416
|
+
*
|
|
417
|
+
* Compared rather than read: a caller holds the number from when it started
|
|
418
|
+
* waiting and asks whether it still holds, which is how a re-arm ends a wait
|
|
419
|
+
* that was looking for file paths.
|
|
420
|
+
*/
|
|
421
|
+
armGeneration(path) {
|
|
422
|
+
return this.#armGen.get(path) ?? 0;
|
|
423
|
+
}
|
|
424
|
+
/** The last thing this tree said it had lost, if it has said one since arming. */
|
|
425
|
+
lostFor(path) {
|
|
426
|
+
return this.#watchLost.get(path);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Whether this host honoured the nomination at all.
|
|
430
|
+
*
|
|
431
|
+
* `undefined` while nothing has been nominated or no opening frame has been
|
|
432
|
+
* seen since. `false` is a host with no `file.changed` — see
|
|
433
|
+
* {@link #watchingEchoed}.
|
|
434
|
+
*/
|
|
435
|
+
get watchesHonoured() {
|
|
436
|
+
return this.#watchingEchoed;
|
|
437
|
+
}
|
|
438
|
+
/** Whether the connection now open nominated this tree and has greeted. */
|
|
439
|
+
nominationLive(path) {
|
|
440
|
+
return this.#greeted && this.#sent.includes(path);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Whether this tree is being watched RIGHT NOW, connection included.
|
|
444
|
+
*
|
|
445
|
+
* The question {@link isArmed} does not answer and the one a quiet answer has
|
|
446
|
+
* to be built on. `#armed` is what the last opening frame said, and it is
|
|
447
|
+
* deliberately not cleared when a socket dies — clearing it would make every
|
|
448
|
+
* routine reconnect look like a re-arm, which is a "go and re-read the tree"
|
|
449
|
+
* for an interruption the platform's own replay covered. But a tree on a
|
|
450
|
+
* connection that is not up is not being watched at this instant, and a tool
|
|
451
|
+
* that said "watched for the whole of that and still is" while reconnecting
|
|
452
|
+
* would be wrong about the half of that sentence it can actually check.
|
|
453
|
+
*/
|
|
454
|
+
watchLive(path) {
|
|
455
|
+
return this.nominationLive(path) && this.isArmed(path);
|
|
456
|
+
}
|
|
457
|
+
/** Whether this stream is still nominating this tree at all. */
|
|
458
|
+
nominates(path) {
|
|
459
|
+
return this.#watches.includes(path);
|
|
460
|
+
}
|
|
461
|
+
/** Whether the connection carrying this tree was refused. See {@link WATCH_SHED_AFTER}. */
|
|
462
|
+
watchWasRefused(path) {
|
|
463
|
+
return this.#watchRefused.has(path);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Whether this tree went unwatched between a previous subscription and this
|
|
467
|
+
* one, and has not been told about it yet. Reading it CLEARS it: it is a
|
|
468
|
+
* thing to say once.
|
|
469
|
+
*/
|
|
470
|
+
takeInterruption(path) {
|
|
471
|
+
return this.#interrupted.delete(path);
|
|
472
|
+
}
|
|
473
|
+
/** Mark it in use, so the idle sweep leaves it alone. */
|
|
474
|
+
touch() {
|
|
475
|
+
this.#lastUsed = Date.now();
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Ask this stream to report file changes under a tree.
|
|
479
|
+
*
|
|
480
|
+
* The one thing on this socket that has to be ASKED for: without a nomination
|
|
481
|
+
* no `file.changed` can arrive at all, which makes a watch a connection
|
|
482
|
+
* parameter rather than an event type to add to a list. So nominating
|
|
483
|
+
* something new reopens the connection — with this subscription's own
|
|
484
|
+
* `since`, so the reconnect resumes rather than restarts and nothing on the
|
|
485
|
+
* stream is missed by it.
|
|
486
|
+
*
|
|
487
|
+
* Already-nominated is a no-op that moves the tree to the front of the queue,
|
|
488
|
+
* and that ordering is what {@link MAX_WATCHES} costs: a fifth tree evicts the
|
|
489
|
+
* one nobody has asked about for longest. Evicting rather than refusing is
|
|
490
|
+
* deliberate — a model that has moved on to a different directory should not
|
|
491
|
+
* have to know that four earlier ones are in the way — but it is never
|
|
492
|
+
* SILENT, because a dropped watch is a tree that stops reporting. The caller
|
|
493
|
+
* is handed what went and says so.
|
|
494
|
+
*
|
|
495
|
+
* It says only what it EVICTED, and not whether it reopened anything. The
|
|
496
|
+
* caller has a sharper question than "did this change something" —
|
|
497
|
+
* {@link nominationLive}, which asks whether the open connection is carrying
|
|
498
|
+
* the tree — and the two differ in the case that matters: a nomination this
|
|
499
|
+
* call did not change can still be off the wire, because the connection
|
|
500
|
+
* carrying it dropped a moment ago.
|
|
501
|
+
*/
|
|
502
|
+
nominate(path) {
|
|
503
|
+
this.touch();
|
|
504
|
+
// A nomination is also a RETRY, and this is where that is honoured. Whatever
|
|
505
|
+
// this stream concluded about this path last time — that its connection
|
|
506
|
+
// would not open, that it is the one under suspicion for that — is a fact
|
|
507
|
+
// about a moment that has passed: the computer may since have dropped below
|
|
508
|
+
// its tree limit, and the directory may since exist. "Call again" is the
|
|
509
|
+
// advice every one of those refusals gives, so calling again has to mean
|
|
510
|
+
// something.
|
|
511
|
+
const retrying = this.#watchRefused.delete(path) || this.#shedCandidate === path;
|
|
512
|
+
if (this.#shedCandidate === path)
|
|
513
|
+
this.#shedCandidate = undefined;
|
|
514
|
+
this.#upgradeFailures = 0;
|
|
515
|
+
this.#shedRuledOut = false;
|
|
516
|
+
const at = this.#watches.indexOf(path);
|
|
517
|
+
if (at >= 0) {
|
|
518
|
+
// Most recently asked about goes last, so the eviction below always takes
|
|
519
|
+
// the tree that has waited longest for somebody to care about it. Which
|
|
520
|
+
// tree gets BLAMED for a stream that will not open is a different order
|
|
521
|
+
// and deliberately not this one — see `#blamed`.
|
|
522
|
+
this.#watches.splice(at, 1);
|
|
523
|
+
this.#watches.push(path);
|
|
524
|
+
// Already on the wire and not under suspicion, so there is nothing to
|
|
525
|
+
// reopen for. A tree being retried is neither.
|
|
526
|
+
if (!retrying && this.#sent.includes(path))
|
|
527
|
+
return {};
|
|
528
|
+
this.#renominate = true;
|
|
529
|
+
this.#socket?.close();
|
|
530
|
+
this.#wakeAll();
|
|
531
|
+
return {};
|
|
532
|
+
}
|
|
533
|
+
this.#watches.push(path);
|
|
534
|
+
let evicted;
|
|
535
|
+
if (this.#watches.length > MAX_WATCHES) {
|
|
536
|
+
evicted = this.#watches.shift();
|
|
537
|
+
if (evicted !== undefined) {
|
|
538
|
+
this.#armed.delete(evicted);
|
|
539
|
+
this.#watchLost.delete(evicted);
|
|
540
|
+
this.#hostName.delete(evicted);
|
|
541
|
+
this.#interrupted.delete(evicted);
|
|
542
|
+
this.#watchRefused.delete(evicted);
|
|
543
|
+
// An experiment about a tree nobody nominates any more has nothing left
|
|
544
|
+
// to prove, and letting it finish would file a refusal against a path
|
|
545
|
+
// this stream is no longer asking for.
|
|
546
|
+
if (this.#shedCandidate === evicted)
|
|
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.
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Closed rather than aborted: `#loop` is still running and its next turn
|
|
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();
|
|
562
|
+
this.#wakeAll();
|
|
563
|
+
return { evicted };
|
|
564
|
+
}
|
|
565
|
+
/** Open the socket, if it is not already open. Returns at once. */
|
|
566
|
+
start() {
|
|
567
|
+
if (this.#running)
|
|
568
|
+
return;
|
|
569
|
+
this.#running = true;
|
|
570
|
+
void this.#run();
|
|
571
|
+
}
|
|
572
|
+
/** Close the socket and stop reconnecting. The buffer goes with it. */
|
|
573
|
+
close(reason = 'the subscription was closed') {
|
|
574
|
+
this.#abort.abort();
|
|
575
|
+
this.#state = { status: 'stopped', reason };
|
|
576
|
+
this.#socket?.close();
|
|
577
|
+
this.#socket = undefined;
|
|
578
|
+
this.#wakeAll();
|
|
579
|
+
}
|
|
580
|
+
/** The index of the oldest event still buffered. */
|
|
581
|
+
get #oldest() {
|
|
582
|
+
return this.#ring.length ? this.#ring[0].index : this.#nextIndex;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Everything the model has not been handed, oldest first.
|
|
586
|
+
*
|
|
587
|
+
* `since` is an override rather than the ordinary way in. A model that passes
|
|
588
|
+
* nothing gets what it has not seen, which is what "what happened while I was
|
|
589
|
+
* thinking" means and is the call that cannot be got wrong; a model that
|
|
590
|
+
* keeps a cursor can rewind or resume with one. Both are the same position in
|
|
591
|
+
* the end — this class holds it either way.
|
|
592
|
+
*/
|
|
593
|
+
read(opts = { limit: 100 }) {
|
|
594
|
+
this.touch();
|
|
595
|
+
const attached = !this.#read;
|
|
596
|
+
this.#read = true;
|
|
597
|
+
const from = this.resolveFrom(opts.since);
|
|
598
|
+
const end = opts.through !== undefined ? opts.through + 1 : this.#nextIndex;
|
|
599
|
+
const window = this.#ring.filter((b) => b.index >= from && b.index < end);
|
|
600
|
+
// The OLDEST `limit`, not the newest, and the position advances only over
|
|
601
|
+
// what is actually returned — so a batch that does not fit leaves the rest
|
|
602
|
+
// buffered for the next call rather than dropping it. `through` is the one
|
|
603
|
+
// exception: a wait has already promised to return the event it matched, so
|
|
604
|
+
// it keeps the tail and says how much of the head it had to leave behind.
|
|
605
|
+
let batch = window;
|
|
606
|
+
let omitted = 0;
|
|
607
|
+
if (window.length > opts.limit) {
|
|
608
|
+
if (opts.through !== undefined) {
|
|
609
|
+
batch = window.slice(window.length - opts.limit);
|
|
610
|
+
omitted = window.length - opts.limit;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
batch = window.slice(0, opts.limit);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const last = batch.length ? batch[batch.length - 1] : undefined;
|
|
617
|
+
if (last && last.index + 1 > this.#delivered) {
|
|
618
|
+
const at = last.event.cursor;
|
|
619
|
+
if (typeof at === 'string' && at)
|
|
620
|
+
this.#deliveredCursor = at;
|
|
621
|
+
}
|
|
622
|
+
this.#delivered = Math.max(this.#delivered, last ? last.index + 1 : from);
|
|
623
|
+
const loss = omitted
|
|
624
|
+
? {
|
|
625
|
+
// An unknown count plus a known one is still unknown. Adding the two
|
|
626
|
+
// would report a precise number for a hole nobody can measure, which
|
|
627
|
+
// is the one thing a loss report must not do.
|
|
628
|
+
events: this.#loss?.events === null ? null : (this.#loss?.events ?? 0) + omitted,
|
|
629
|
+
reason: this.#loss
|
|
630
|
+
? `${this.#loss.reason}; and ${omitted} more than limit allowed were stepped over to reach the event you waited for`
|
|
631
|
+
: `${omitted} events older than the one you waited for did not fit in limit and were stepped over`,
|
|
632
|
+
}
|
|
633
|
+
: this.#loss;
|
|
634
|
+
this.#loss = undefined;
|
|
635
|
+
return {
|
|
636
|
+
events: batch.map((b) => b.event),
|
|
637
|
+
cursor: this.#position(last),
|
|
638
|
+
more: Math.max(0, this.#nextIndex - this.#delivered),
|
|
639
|
+
loss,
|
|
640
|
+
hello: this.#hello,
|
|
641
|
+
attached,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Where a read or a wait starts: the model's own place, or the cursor it named.
|
|
646
|
+
*
|
|
647
|
+
* A cursor this buffer cannot place is not an error and not silence. It may
|
|
648
|
+
* be from before the socket opened, from before a reap, or simply not one of
|
|
649
|
+
* ours; either way the events between there and here are not something this
|
|
650
|
+
* server holds, and saying so is the whole of the gap discipline — never a
|
|
651
|
+
* frame the model has to interpret, always a sentence and, from the tool, the
|
|
652
|
+
* state it would otherwise have gone to reconcile against.
|
|
653
|
+
*/
|
|
654
|
+
resolveFrom(since) {
|
|
655
|
+
// Where the model is, which is not where the socket is: it may be four
|
|
656
|
+
// turns behind, and everything between the two is exactly what it has not
|
|
657
|
+
// been handed yet.
|
|
658
|
+
if (since === undefined)
|
|
659
|
+
return Math.max(this.#delivered, this.#oldest);
|
|
660
|
+
const at = this.#ring.findIndex((b) => b.event.cursor === since);
|
|
661
|
+
if (at >= 0)
|
|
662
|
+
return this.#ring[at].index + 1;
|
|
663
|
+
// The position at the moment this connection attached, which is
|
|
664
|
+
// legitimately older than anything in the ring on a quiet computer. The one
|
|
665
|
+
// deliberate rewind: a caller asking for it is asking to be re-sent this
|
|
666
|
+
// connection's whole buffer, and saying so exactly.
|
|
667
|
+
if (since === this.#hello?.cursor)
|
|
668
|
+
return this.#oldest;
|
|
669
|
+
// Otherwise: the unread frontier, NOT the oldest thing still in the ring.
|
|
670
|
+
// A delivered event stays in the ring until the cap evicts it, so answering
|
|
671
|
+
// an unplaceable cursor with `#oldest` re-sent events the model already
|
|
672
|
+
// had — while attaching a loss note that said they "were not kept", which
|
|
673
|
+
// was false about exactly the events being re-sent.
|
|
674
|
+
this.#loss ??= {
|
|
675
|
+
events: null,
|
|
676
|
+
reason: 'that cursor is not a place this session can find, so whatever happened between it and ' +
|
|
677
|
+
'the events below was not kept here',
|
|
678
|
+
};
|
|
679
|
+
return Math.max(this.#delivered, this.#oldest);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Wait for an event this predicate accepts, or for the deadline.
|
|
683
|
+
*
|
|
684
|
+
* Resolves with the matching event's index, or `undefined` for a wait that
|
|
685
|
+
* ended without one — a deadline, a caller who hung up, or a stream that
|
|
686
|
+
* stopped. None of those three is an error here and the caller says which.
|
|
687
|
+
*/
|
|
688
|
+
async waitFor(matches, deadline, cancel, since, abandon) {
|
|
689
|
+
this.touch();
|
|
690
|
+
const start = this.resolveFrom(since);
|
|
691
|
+
for (;;) {
|
|
692
|
+
// Clamped to what is still buffered on every turn, because the ring can
|
|
693
|
+
// evict underneath a long wait. An event that arrived two turns ago and
|
|
694
|
+
// has not been read still satisfies a wait for it — a wait that only ever
|
|
695
|
+
// looked forward would hang on something that had already happened, which
|
|
696
|
+
// is the defect `hello.ready` exists to prevent one level down.
|
|
697
|
+
const from = Math.max(start, this.#oldest);
|
|
698
|
+
const hit = this.#ring.find((b) => b.index >= from && matches(b.event));
|
|
699
|
+
if (hit)
|
|
700
|
+
return hit.index;
|
|
701
|
+
if (deadline.aborted || cancel?.aborted)
|
|
702
|
+
return undefined;
|
|
703
|
+
if (this.#state.status === 'stopped')
|
|
704
|
+
return undefined;
|
|
705
|
+
// Asked on every wake, AFTER the buffered check, so an event that is
|
|
706
|
+
// already here still wins. This is what makes a `capabilities` frame
|
|
707
|
+
// arriving mid-wait answerable: the frame wakes every parked waiter but
|
|
708
|
+
// is not itself an event, so a loop that only re-ran `matches` saw
|
|
709
|
+
// nothing, parked again, and sat out the whole deadline on a computer
|
|
710
|
+
// that could no longer produce what it was waiting for.
|
|
711
|
+
if (abandon?.())
|
|
712
|
+
return undefined;
|
|
713
|
+
await this.#park(deadline, cancel);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Wait until this subscription has an opening frame, or has stopped.
|
|
718
|
+
*
|
|
719
|
+
* Separate from {@link waitFor} rather than expressed as a predicate over it,
|
|
720
|
+
* and the reason is worth keeping: `waitFor` evaluates its predicate against
|
|
721
|
+
* BUFFERED EVENTS, so a condition that is not about an event — "has hello
|
|
722
|
+
* landed" — is never evaluated at all on a computer where nothing is
|
|
723
|
+
* happening. Written that way it does not answer late, it answers at the
|
|
724
|
+
* deadline, on the one call where the deadline is twenty seconds.
|
|
725
|
+
*/
|
|
726
|
+
async attached(deadline, cancel) {
|
|
727
|
+
while (!this.#types && this.#state.status !== 'stopped') {
|
|
728
|
+
if (deadline.aborted || cancel?.aborted)
|
|
729
|
+
return;
|
|
730
|
+
await this.#park(deadline, cancel);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Wait until the open connection is carrying this tree and has greeted.
|
|
735
|
+
*
|
|
736
|
+
* The counterpart to {@link attached} for a nomination, and separate for the
|
|
737
|
+
* reason the two of them are separate from {@link waitFor}: what is being
|
|
738
|
+
* waited for is not an event, so a predicate over the ring would never be
|
|
739
|
+
* evaluated on a computer where nothing is happening. `attached` cannot serve
|
|
740
|
+
* here either — it is satisfied the moment `#types` is set, which a
|
|
741
|
+
* nomination does not clear, so it would return at once and leave the caller
|
|
742
|
+
* reading the PREVIOUS connection's `watching`.
|
|
743
|
+
*/
|
|
744
|
+
async nominated(path, deadline, cancel) {
|
|
745
|
+
while (!this.nominationLive(path) && this.#state.status !== 'stopped') {
|
|
746
|
+
if (deadline.aborted || cancel?.aborted)
|
|
747
|
+
return;
|
|
748
|
+
if (this.#settledAgainst(path))
|
|
749
|
+
return;
|
|
750
|
+
await this.#park(deadline, cancel);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Whether waiting for this tree to come live has stopped being worth doing.
|
|
755
|
+
*
|
|
756
|
+
* The same four questions the change wait asks on every wake, asked by the
|
|
757
|
+
* two waits that come BEFORE it — which used to park through all of them and
|
|
758
|
+
* then answer "it is not being watched yet, the nomination stands, call
|
|
759
|
+
* again". Every word of that is false when the tree has been evicted by
|
|
760
|
+
* another call, given up on as one this host will not carry, or when the
|
|
761
|
+
* computer has stopped being able to report file changes at all. Each of
|
|
762
|
+
* those is an answer this server already has, and parking on it turns an
|
|
763
|
+
* answer into a timeout the caller has to interpret.
|
|
764
|
+
*/
|
|
765
|
+
#settledAgainst(path) {
|
|
766
|
+
if (this.#watchRefused.has(path))
|
|
767
|
+
return true;
|
|
768
|
+
if (!this.#watches.includes(path))
|
|
769
|
+
return true;
|
|
770
|
+
const types = this.#types;
|
|
771
|
+
if (types?.length && !types.includes('file.changed'))
|
|
772
|
+
return true;
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Wait until this tree is actually being watched.
|
|
777
|
+
*
|
|
778
|
+
* The whole reason a file watch needs its own wait. `hello` accepting a
|
|
779
|
+
* nomination is not the tree being watched: the guest has to be asked, and on
|
|
780
|
+
* a computer nobody has opened a terminal on the host installs the watcher
|
|
781
|
+
* into the guest first — seconds, not milliseconds. inotify reports changes
|
|
782
|
+
* and not state, so nothing that happens in that window is ever reported.
|
|
783
|
+
* Returning before this is what makes a tool say "nothing changed" about a
|
|
784
|
+
* window during which nothing was watching, which is the one sentence a
|
|
785
|
+
* server whose whole promise is that it was listening must not say.
|
|
786
|
+
*
|
|
787
|
+
* Returns on the deadline, on a stopped stream, and on an `unwatchable` —
|
|
788
|
+
* which is the one `lost` that means the tree is not being watched at all,
|
|
789
|
+
* and so is not something more waiting will fix. The caller reads
|
|
790
|
+
* {@link isArmed} to find out which it got.
|
|
791
|
+
*/
|
|
792
|
+
async armedWait(path, deadline, cancel) {
|
|
793
|
+
while (!this.isArmed(path) && this.#state.status !== 'stopped') {
|
|
794
|
+
if (deadline.aborted || cancel?.aborted)
|
|
795
|
+
return;
|
|
796
|
+
if (this.#watchLost.get(path) === 'unwatchable')
|
|
797
|
+
return;
|
|
798
|
+
if (this.#settledAgainst(path))
|
|
799
|
+
return;
|
|
800
|
+
await this.#park(deadline, cancel);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** Sleep until something changes here, or until either signal fires. */
|
|
804
|
+
#park(deadline, cancel) {
|
|
805
|
+
return new Promise((resolve) => {
|
|
806
|
+
const done = () => {
|
|
807
|
+
this.#wake.delete(done);
|
|
808
|
+
deadline.removeEventListener('abort', done);
|
|
809
|
+
cancel?.removeEventListener('abort', done);
|
|
810
|
+
resolve();
|
|
811
|
+
};
|
|
812
|
+
this.#wake.add(done);
|
|
813
|
+
deadline.addEventListener('abort', done, { once: true });
|
|
814
|
+
cancel?.addEventListener('abort', done, { once: true });
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/** The cursor for a position, or the connection's own when nothing was read. */
|
|
818
|
+
#position(last) {
|
|
819
|
+
const cursor = last?.event.cursor;
|
|
820
|
+
if (typeof cursor === 'string' && cursor)
|
|
821
|
+
return cursor;
|
|
822
|
+
return this.#resume ?? this.#hello?.cursor ?? '';
|
|
823
|
+
}
|
|
824
|
+
#wakeAll() {
|
|
825
|
+
for (const wake of [...this.#wake])
|
|
826
|
+
wake();
|
|
827
|
+
}
|
|
828
|
+
#push(event) {
|
|
829
|
+
this.#ring.push({ index: this.#nextIndex++, event });
|
|
830
|
+
if (this.#ring.length > MAX_BUFFERED) {
|
|
831
|
+
const evicted = this.#ring.splice(0, this.#ring.length - MAX_BUFFERED);
|
|
832
|
+
// Only what the model had not been handed is a loss. Dropping events it
|
|
833
|
+
// already read is the ring doing its job, and counting those would report
|
|
834
|
+
// a hole where there is none.
|
|
835
|
+
const unread = evicted.filter((b) => b.index >= this.#delivered).length;
|
|
836
|
+
if (unread) {
|
|
837
|
+
const before = this.#loss?.events;
|
|
838
|
+
this.#loss = {
|
|
839
|
+
events: before === null ? null : (before ?? 0) + unread,
|
|
840
|
+
reason: `more than ${MAX_BUFFERED} events went unread on this computer, so the oldest were ` +
|
|
841
|
+
'dropped here rather than by the platform',
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
this.#wakeAll();
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Connect, read, reconnect. Runs until {@link close} or until something says
|
|
849
|
+
* that reopening cannot help.
|
|
850
|
+
*/
|
|
851
|
+
async #run() {
|
|
852
|
+
try {
|
|
853
|
+
await this.#loop();
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
// Nothing awaits this loop, so anything escaping it would be an
|
|
857
|
+
// unhandled rejection and a stream that had silently stopped. Both are
|
|
858
|
+
// worse than the sentence.
|
|
859
|
+
this.#state = {
|
|
860
|
+
status: 'stopped',
|
|
861
|
+
reason: `the event stream failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
862
|
+
};
|
|
863
|
+
this.#wakeAll();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async #loop() {
|
|
867
|
+
let backoff = BACKOFF_MS;
|
|
868
|
+
while (!this.#abort.signal.aborted) {
|
|
869
|
+
let url;
|
|
870
|
+
try {
|
|
871
|
+
url = await this.#url();
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
if (err instanceof SettledError) {
|
|
875
|
+
this.#state = { status: 'stopped', reason: err.message };
|
|
876
|
+
this.#wakeAll();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
this.#state = { status: 'connecting' };
|
|
880
|
+
await sleep(backoff, this.#abort.signal);
|
|
881
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
// Checked HERE, between the read and the socket, because `#url()` is a
|
|
885
|
+
// network round trip and a close can land inside it — a session ending, a
|
|
886
|
+
// computer deleted, an idle drop. Without this the socket opened after
|
|
887
|
+
// the abort is one nothing holds a reference to and nothing will ever
|
|
888
|
+
// close: `close()` already ran, `#socket` was undefined at the time, and
|
|
889
|
+
// an `abort` listener added to a signal that has ALREADY fired never
|
|
890
|
+
// fires. It would sit open and buffering into an object nobody can read,
|
|
891
|
+
// for as long as the process lives.
|
|
892
|
+
if (this.#abort.signal.aborted)
|
|
893
|
+
return;
|
|
894
|
+
const reached = await this.#connection(url);
|
|
895
|
+
if (this.#abort.signal.aborted)
|
|
896
|
+
return;
|
|
897
|
+
// A reconnection THIS side asked for, to put a new watch set on the URL.
|
|
898
|
+
// ASKED FIRST, before anything reads `reached`, because a connection this
|
|
899
|
+
// client closed did not fail: `nominate` closes the socket, and if it does
|
|
900
|
+
// so before the opening frame lands then `reached` is false for a reason
|
|
901
|
+
// that has nothing to do with the host. Counted as a failure it is
|
|
902
|
+
// evidence against a watch — and two of them, which is one re-nomination
|
|
903
|
+
// of an in-flight connection plus one ordinary blip, used to shed a tree
|
|
904
|
+
// and report it as one the host would not carry.
|
|
905
|
+
//
|
|
906
|
+
// It also skips the floor below, which exists for a host that keeps
|
|
907
|
+
// hanging up on us rather than for a connection we closed on purpose;
|
|
908
|
+
// paying it would put half a second in front of every first watch of a
|
|
909
|
+
// tree, under an arming the caller is already waiting on.
|
|
910
|
+
if (this.#renominate) {
|
|
911
|
+
this.#renominate = false;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
// A connection that got as far as its opening frame is not a failure,
|
|
915
|
+
// however soon it died: a computer that restarts drops the socket every
|
|
916
|
+
// time, and treating that as a failed attempt would back a healthy
|
|
917
|
+
// stream off to fifteen seconds for doing what it always does.
|
|
918
|
+
backoff = reached ? BACKOFF_MS : Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
919
|
+
// Only the failing half is driven from here. A connection that WORKED is
|
|
920
|
+
// not a connection that has ended, and this line runs when one ends —
|
|
921
|
+
// which for a healthy stream is minutes or hours later, or never. The
|
|
922
|
+
// other half is in `#onHello`, where reaching the opening frame actually
|
|
923
|
+
// happens.
|
|
924
|
+
if (!reached)
|
|
925
|
+
this.#blamed();
|
|
926
|
+
// A floor even on a connection that worked. A host that sends `hello`
|
|
927
|
+
// and closes — which is what a subscriber being put down for not reading
|
|
928
|
+
// looks like from here — would otherwise be a reconnect loop with no
|
|
929
|
+
// interval at all, one `GET computers/:id` per turn of the event loop.
|
|
930
|
+
if (!this.#abort.signal.aborted) {
|
|
931
|
+
await sleep(reached ? BACKOFF_MS : backoff, this.#abort.signal);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* A fresh `events_url`, on every connection and every reconnect.
|
|
937
|
+
*
|
|
938
|
+
* Re-read rather than cached, because the credential in it is rotated by a
|
|
939
|
+
* restart — and a restart is one of the ordinary reasons the socket dropped
|
|
940
|
+
* in the first place. A reconnect over the old URL is a 401 that arrives as a
|
|
941
|
+
* socket which closes with no status and no body, which is the least
|
|
942
|
+
* debuggable failure this file can produce.
|
|
943
|
+
*
|
|
944
|
+
* The read is also what answers the two refusals a websocket cannot report.
|
|
945
|
+
* The reference says a suspended computer is refused with `409` and
|
|
946
|
+
* `resume_required`, and a stopped one with `409 unavailable`; neither status
|
|
947
|
+
* nor body reaches a `WebSocket` client, so every client of this stream has
|
|
948
|
+
* to infer them. Here they are not inferred at all — the status is on the
|
|
949
|
+
* record this call already had to make for the URL.
|
|
950
|
+
*/
|
|
951
|
+
async #url() {
|
|
952
|
+
let c;
|
|
953
|
+
try {
|
|
954
|
+
// Bound to this subscription's own signal. Without it a `closeAll()` on
|
|
955
|
+
// session teardown leaves this fetch running to undici's 330-second
|
|
956
|
+
// header timeout, with `#loop` parked inside it the whole time.
|
|
957
|
+
c = unwrapComputer(await this.#api.with(this.#abort.signal).json('GET', P.computer(this.computerId)));
|
|
958
|
+
}
|
|
959
|
+
catch (err) {
|
|
960
|
+
// A question already answered ends the stream rather than being asked
|
|
961
|
+
// again behind it: a deleted computer or a revoked key is otherwise a
|
|
962
|
+
// reconnect loop with nothing to stop it, asking forever and never
|
|
963
|
+
// saying the answer out loud.
|
|
964
|
+
if (err instanceof Error && !isTransientForPoll(err))
|
|
965
|
+
throw new SettledError(err.message);
|
|
966
|
+
throw err;
|
|
967
|
+
}
|
|
968
|
+
const status = c.status ?? 'unknown';
|
|
969
|
+
if (status === 'suspended') {
|
|
970
|
+
throw new SettledError(`${this.computerId} suspended, and the event stream is the one part of this API that does ` +
|
|
971
|
+
'not resume a computer for you. Listening is not using, so a computer nobody touches ' +
|
|
972
|
+
'suspends underneath its own stream. start_computer, then ask again.');
|
|
973
|
+
}
|
|
974
|
+
if (status === 'stopped' || status === 'build-failed') {
|
|
975
|
+
throw new SettledError(`${this.computerId} is ${status}, and only a running computer has an event stream. ` +
|
|
976
|
+
'start_computer, then ask again.');
|
|
977
|
+
}
|
|
978
|
+
if (status !== 'running') {
|
|
979
|
+
// `starting`, `moving`, `creating` — states that clear on their own, so
|
|
980
|
+
// they get the backoff rather than the refusal. Settling on everything
|
|
981
|
+
// that was not `running` broke the flow the README advertises: a
|
|
982
|
+
// create_computer followed at once by wait_for_event("computer.ready")
|
|
983
|
+
// meets `starting`, which is the ordinary weather of a machine coming up
|
|
984
|
+
// and is precisely what the caller is waiting through.
|
|
985
|
+
throw new MandalaError(`${this.computerId} is ${status}; waiting for it to be running`);
|
|
986
|
+
}
|
|
987
|
+
const vnc = c.vnc;
|
|
988
|
+
const url = typeof vnc?.events_url === 'string' ? vnc.events_url : undefined;
|
|
989
|
+
if (url) {
|
|
990
|
+
// Parsed HERE, where every other events_url decision is made, rather than
|
|
991
|
+
// beside the socket. A string that is not a URL is the same fact as the
|
|
992
|
+
// missing one below — there is nothing to connect to — and this method is
|
|
993
|
+
// the one place that says so with a sentence instead of an exception.
|
|
994
|
+
//
|
|
995
|
+
// It used to throw from inside the connection's Promise executor, which
|
|
996
|
+
// REJECTED: nothing between there and `#run` catches, so the subscription
|
|
997
|
+
// reached a terminal `stopped` reading "the event stream failed: Invalid
|
|
998
|
+
// URL". Settling it is the same outcome said properly. Retrying it
|
|
999
|
+
// forever would be worse than either — the URL is re-read on every
|
|
1000
|
+
// attempt, so a value the platform keeps sending is a poll of
|
|
1001
|
+
// `GET /computers/:id` every fifteen seconds for the life of the session,
|
|
1002
|
+
// under a wait that keeps answering "nothing happened".
|
|
1003
|
+
try {
|
|
1004
|
+
new URL(url);
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
// The URL is not interpolated. `events_url` lives inside `vnc`, which
|
|
1008
|
+
// is root-equivalent on the machine, and this sentence becomes
|
|
1009
|
+
// `state.reason` which poll_events prints. JSON-stringifying the raw
|
|
1010
|
+
// value put the token `withoutCredentials` exists to strip into model
|
|
1011
|
+
// context — a relative URL with a query token is the ordinary shape of
|
|
1012
|
+
// "the platform omitted the host" (adversarial review, OPL-4314).
|
|
1013
|
+
throw new SettledError(`${this.computerId} has an events_url this client cannot parse, ` +
|
|
1014
|
+
'so there is nowhere to connect. This is the platform sending something unexpected rather ' +
|
|
1015
|
+
'than a passing condition — screenshot and list_windows still work.');
|
|
1016
|
+
}
|
|
1017
|
+
return url;
|
|
1018
|
+
}
|
|
1019
|
+
if (!vnc) {
|
|
1020
|
+
// The platform could not reach the host holding this computer, so it sent
|
|
1021
|
+
// no connect surface at all. Weather, and the backoff is the right
|
|
1022
|
+
// response to it — deliberately not settled.
|
|
1023
|
+
throw new MandalaError(`the platform returned no connect surface for ${this.computerId}; its host may be unreachable`);
|
|
1024
|
+
}
|
|
1025
|
+
if (c.os === 'windows') {
|
|
1026
|
+
throw new SettledError(`${this.computerId} runs Windows, which has no event stream: there is nowhere in the ` +
|
|
1027
|
+
'guest to run the watcher its guest half needs. Use screenshot and list_windows.');
|
|
1028
|
+
}
|
|
1029
|
+
throw new SettledError(`${this.computerId} has no events_url. Its host may predate the event stream (platform ` +
|
|
1030
|
+
'OPL-3785), or this API key may be a watch-only one, which is not given window titles.');
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* One connection, from the handshake to the close.
|
|
1034
|
+
*
|
|
1035
|
+
* Resolves `true` when the opening frame landed, which is what the caller
|
|
1036
|
+
* uses to tell a stream that keeps dropping from one that never started.
|
|
1037
|
+
*/
|
|
1038
|
+
#connection(url) {
|
|
1039
|
+
// The same check as the one in `#loop`, kept here as well because this is
|
|
1040
|
+
// the method that would leak the socket, and a second caller must not be
|
|
1041
|
+
// able to reintroduce the leak by forgetting.
|
|
1042
|
+
if (this.#abort.signal.aborted)
|
|
1043
|
+
return Promise.resolve(false);
|
|
1044
|
+
this.#greeted = false;
|
|
1045
|
+
return new Promise((resolve) => {
|
|
1046
|
+
// Whether this connection can be handed events it missed. A connection
|
|
1047
|
+
// with no continuity is joining at the head, and everything before it is
|
|
1048
|
+
// simply not this stream's to report.
|
|
1049
|
+
const resuming = Boolean(this.#resume);
|
|
1050
|
+
let socket;
|
|
1051
|
+
try {
|
|
1052
|
+
// `since` is this subscription's own place, never the model's. The two
|
|
1053
|
+
// are different positions on purpose: the model may be four turns behind
|
|
1054
|
+
// and the socket must not re-request what is already in the ring.
|
|
1055
|
+
//
|
|
1056
|
+
// Inside the try with the socket it is for, so that nothing in this
|
|
1057
|
+
// executor can reject: a rejection here escapes `#loop` — which does
|
|
1058
|
+
// not catch — and `#run` turns it into a terminal `stopped`, which is
|
|
1059
|
+
// not what a failure to open one connection means. `#url()` has already
|
|
1060
|
+
// settled a value that cannot parse, so this is belt and braces on the
|
|
1061
|
+
// path that used to throw.
|
|
1062
|
+
const target = new URL(url);
|
|
1063
|
+
if (this.#resume)
|
|
1064
|
+
target.searchParams.set('since', this.#resume);
|
|
1065
|
+
// Repeated rather than comma-joined, which is the platform's own
|
|
1066
|
+
// decision and worth mirroring exactly: a directory may contain a
|
|
1067
|
+
// comma, and a list format that cannot represent every value it is a
|
|
1068
|
+
// list of is a bug waiting for the first tenant with one.
|
|
1069
|
+
//
|
|
1070
|
+
// `append` and not `set`: the second call to `set` replaces the first.
|
|
1071
|
+
target.searchParams.delete('watch');
|
|
1072
|
+
// Snapshotted as it goes on the URL. This is what the opening frame's
|
|
1073
|
+
// `watching` is measured against, and what says whether a nomination
|
|
1074
|
+
// made a moment ago is on this connection or on the next one.
|
|
1075
|
+
//
|
|
1076
|
+
// The nominations MINUS whatever is being withheld: a tree proven to be
|
|
1077
|
+
// one this host will not carry, and the one currently under suspicion
|
|
1078
|
+
// for it. Both stay in `#watches`, because they are still what the
|
|
1079
|
+
// caller asked for and the difference belongs on the wire rather than
|
|
1080
|
+
// in this client's record of the request.
|
|
1081
|
+
this.#sent = this.#watches.filter((w) => !this.#watchRefused.has(w) && w !== this.#shedCandidate);
|
|
1082
|
+
for (const w of this.#sent)
|
|
1083
|
+
target.searchParams.append('watch', w);
|
|
1084
|
+
socket = this.#socketFor(target.toString());
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return resolve(false);
|
|
1088
|
+
}
|
|
1089
|
+
this.#socket = socket;
|
|
1090
|
+
if (this.#state.status !== 'stopped')
|
|
1091
|
+
this.#state = { status: 'connecting' };
|
|
1092
|
+
let settled = false;
|
|
1093
|
+
let opened = false;
|
|
1094
|
+
const finish = (reached) => {
|
|
1095
|
+
if (settled)
|
|
1096
|
+
return;
|
|
1097
|
+
settled = true;
|
|
1098
|
+
clearTimeout(timer);
|
|
1099
|
+
this.#abort.signal.removeEventListener('abort', onAbort);
|
|
1100
|
+
// A connection that has ended is not carrying anything, whatever it was
|
|
1101
|
+
// greeted with. Without this the window between one connection ending
|
|
1102
|
+
// and the next opening reads as a tree still on the wire.
|
|
1103
|
+
this.#greeted = false;
|
|
1104
|
+
if (this.#socket === socket)
|
|
1105
|
+
this.#socket = undefined;
|
|
1106
|
+
try {
|
|
1107
|
+
socket.close();
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
// A socket that is already closed throws on some implementations and
|
|
1111
|
+
// not on others, and there is nothing to do about it either way.
|
|
1112
|
+
}
|
|
1113
|
+
if (this.#state.status !== 'stopped')
|
|
1114
|
+
this.#state = { status: 'connecting' };
|
|
1115
|
+
this.#wakeAll();
|
|
1116
|
+
resolve(reached);
|
|
1117
|
+
};
|
|
1118
|
+
// A handshake with nothing behind it — a host that accepts the TCP
|
|
1119
|
+
// connection and never upgrades — would otherwise hold this connection
|
|
1120
|
+
// open forever, and the reconnect that would have found a working host
|
|
1121
|
+
// never runs.
|
|
1122
|
+
const timer = setTimeout(() => finish(opened), CONNECT_TIMEOUT_MS);
|
|
1123
|
+
const onAbort = () => finish(opened);
|
|
1124
|
+
this.#abort.signal.addEventListener('abort', onAbort, { once: true });
|
|
1125
|
+
// Both listeners are guarded, because `finish()` does not stop a socket
|
|
1126
|
+
// from delivering. A `close()` is a handshake rather than an instant, and
|
|
1127
|
+
// a connect-timeout `finish` leaves the socket open by definition — so
|
|
1128
|
+
// frames from connection A could still arrive after `#loop` had given up
|
|
1129
|
+
// on it and opened connection B, into the same subscription: a second
|
|
1130
|
+
// synthesized readiness, events B will replay, a `stopped` state walked
|
|
1131
|
+
// back to `open` with `#loop` already returned.
|
|
1132
|
+
const mine = () => !settled && this.#socket === socket;
|
|
1133
|
+
socket.addEventListener('open', () => {
|
|
1134
|
+
if (!mine())
|
|
1135
|
+
return;
|
|
1136
|
+
this.#state = { status: 'open' };
|
|
1137
|
+
});
|
|
1138
|
+
socket.addEventListener('message', (ev) => {
|
|
1139
|
+
if (!mine())
|
|
1140
|
+
return;
|
|
1141
|
+
const frame = parse(ev.data);
|
|
1142
|
+
if (!frame)
|
|
1143
|
+
return;
|
|
1144
|
+
if (frame.type === 'hello') {
|
|
1145
|
+
opened = true;
|
|
1146
|
+
clearTimeout(timer);
|
|
1147
|
+
this.#onHello(frame, resuming);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
this.#onFrame(frame);
|
|
1151
|
+
});
|
|
1152
|
+
socket.addEventListener('error', () => finish(opened));
|
|
1153
|
+
socket.addEventListener('close', () => finish(opened));
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
#onHello(frame, resuming) {
|
|
1157
|
+
const hello = {
|
|
1158
|
+
computer: str(frame.computer) ?? this.computerId,
|
|
1159
|
+
cursor: str(frame.cursor) ?? '',
|
|
1160
|
+
// TRUE only. A readiness nobody claimed is a readiness to wait for, which
|
|
1161
|
+
// is the recoverable half of being wrong: waiting on a desktop that is up
|
|
1162
|
+
// ends at the caller's timeout, while concluding a desktop is up because
|
|
1163
|
+
// a field was malformed hands the model a screen that is still booting.
|
|
1164
|
+
ready: frame.ready === true,
|
|
1165
|
+
events: list(frame.events) ?? [],
|
|
1166
|
+
windows: Array.isArray(frame.windows) ? frame.windows : undefined,
|
|
1167
|
+
watching: watched(frame.watching),
|
|
1168
|
+
};
|
|
1169
|
+
this.#hello = hello;
|
|
1170
|
+
this.#types = hello.events;
|
|
1171
|
+
this.#greeted = true;
|
|
1172
|
+
// Here and not where the connection ends, which is the only place this
|
|
1173
|
+
// could be observed from otherwise — and a connection that WORKS does not
|
|
1174
|
+
// end for minutes or hours. An experiment whose positive result was only
|
|
1175
|
+
// read on the way out is an experiment with no result.
|
|
1176
|
+
this.#cleared();
|
|
1177
|
+
this.#adoptWatching(hello.watching);
|
|
1178
|
+
this.#resume ??= hello.cursor || undefined;
|
|
1179
|
+
this.#start ??= hello.cursor || undefined;
|
|
1180
|
+
this.#state = { status: 'open' };
|
|
1181
|
+
// `computer.ready` fires once per desktop SESSION, so a stream that
|
|
1182
|
+
// attaches to a machine which has been up for an hour will never be sent
|
|
1183
|
+
// one — and a `wait_for_event(["computer.ready"])` over the raw socket
|
|
1184
|
+
// waits forever on a desktop that is already there. The opening frame says
|
|
1185
|
+
// which it is, and this is that answer arriving in the shape the model is
|
|
1186
|
+
// already reading.
|
|
1187
|
+
//
|
|
1188
|
+
// Only on a connection with no continuity. A resume either already had the
|
|
1189
|
+
// readiness or is about to be handed it out of the backlog, so nothing is
|
|
1190
|
+
// invented there. Per CONNECTION and not latched across them, which is the
|
|
1191
|
+
// lesson of the SDK's OPL-4206: a latch that remembered "already told them"
|
|
1192
|
+
// suppressed the readiness of a desktop the caller had never heard of,
|
|
1193
|
+
// because a display manager can be restarted inside a running computer and
|
|
1194
|
+
// that is a new session. One extra readiness is the cheaper wrong answer.
|
|
1195
|
+
if (hello.ready && !resuming)
|
|
1196
|
+
this.#pushReady(hello.cursor);
|
|
1197
|
+
this.#wakeAll();
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Take the host's word for what this stream is watching.
|
|
1201
|
+
*
|
|
1202
|
+
* Its spelling and not ours, and that is the point: the host normalises a
|
|
1203
|
+
* nomination — a trailing slash and a `.` segment are cleaned away — and the
|
|
1204
|
+
* cleaned form is what every `file.changed` carries in `watch`. A client that
|
|
1205
|
+
* went on matching what it SENT would match nothing the first time the two
|
|
1206
|
+
* differed, and this server cleans a path the same way precisely so that they
|
|
1207
|
+
* do not — which is a reason to check rather than a reason not to look.
|
|
1208
|
+
*
|
|
1209
|
+
* The armed map is REPLACED rather than merged, because a new connection is
|
|
1210
|
+
* where the authoritative answer lives. The guest answers a nomination once,
|
|
1211
|
+
* so a tree somebody else armed sends this connection no event at all and the
|
|
1212
|
+
* opening frame is the only place its state is stated. Merging would leave a
|
|
1213
|
+
* stale `true` on a tree that had since gone unwatchable, which is the
|
|
1214
|
+
* wait-forever bug pointed the other way.
|
|
1215
|
+
*/
|
|
1216
|
+
#adoptWatching(watching) {
|
|
1217
|
+
// Measured against what THIS connection sent rather than against the
|
|
1218
|
+
// current nomination set, which are the same list except in the one case
|
|
1219
|
+
// that matters: a nomination made while a connection was in flight.
|
|
1220
|
+
const sent = this.#sent;
|
|
1221
|
+
// Both cleared whatever the answer. An alias belongs to the connection that
|
|
1222
|
+
// stated it, and a stale one would map a nomination onto a name the host is
|
|
1223
|
+
// no longer using — every event under it dropped as being about a tree
|
|
1224
|
+
// nobody asked for. And a connection carrying no watches is watching
|
|
1225
|
+
// nothing, so a surviving `armed` would be a live tree that is not one.
|
|
1226
|
+
this.#hostName.clear();
|
|
1227
|
+
const armed = new Map();
|
|
1228
|
+
if (!sent.length) {
|
|
1229
|
+
this.#watchingEchoed = undefined;
|
|
1230
|
+
this.#armed = armed;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
// A host answering about a different number of trees than it was asked
|
|
1234
|
+
// about has said something this client cannot line up, and guessing which
|
|
1235
|
+
// nomination it dropped would be inventing the one fact the caller needs.
|
|
1236
|
+
// Read as "not honoured", which is the answer that gets said out loud.
|
|
1237
|
+
this.#watchingEchoed = watching !== undefined && watching.length === sent.length;
|
|
1238
|
+
if (!watching || !this.#watchingEchoed) {
|
|
1239
|
+
this.#armed = armed;
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
sent.forEach((nominated, i) => {
|
|
1243
|
+
const w = watching[i];
|
|
1244
|
+
if (w.path !== nominated)
|
|
1245
|
+
this.#hostName.set(nominated, w.path);
|
|
1246
|
+
armed.set(nominated, w.armed);
|
|
1247
|
+
// A tree that comes up armed on a connection that found it unarmed is a
|
|
1248
|
+
// real transition and bumps the generation; one that was already armed is
|
|
1249
|
+
// the same arming reported again by a socket that reconnected under it,
|
|
1250
|
+
// and bumping there would tell every waiter to go and re-read a tree
|
|
1251
|
+
// nothing had interrupted.
|
|
1252
|
+
//
|
|
1253
|
+
// And the arming is what clears a standing loss, which is why only a
|
|
1254
|
+
// TRANSITION does. The guest reports `budget` when it installs the watch
|
|
1255
|
+
// and finds the tree too big, so a tree that really re-armed is about to
|
|
1256
|
+
// say so again if it is still true — but a connection whose `hello` says
|
|
1257
|
+
// a tree was armed all along did not re-arm anything, and clearing there
|
|
1258
|
+
// would drop a permanent condition that nothing puts back. `hello.watching`
|
|
1259
|
+
// has no field for it, so the flag is the only record there is.
|
|
1260
|
+
if (w.armed && !this.isArmed(nominated)) {
|
|
1261
|
+
this.#bumpArm(nominated);
|
|
1262
|
+
this.#watchLost.delete(nominated);
|
|
1263
|
+
}
|
|
1264
|
+
else if (this.#watchLost.get(nominated) === 'unwatchable') {
|
|
1265
|
+
// Cleared on any new connection, unlike `budget`, because a tree that
|
|
1266
|
+
// is not armed is one the guest is about to be asked about again — and
|
|
1267
|
+
// it will answer `unwatchable` a second time if the directory is still
|
|
1268
|
+
// missing. Carried forward it would refuse a wait on a tree that had
|
|
1269
|
+
// since appeared.
|
|
1270
|
+
this.#watchLost.delete(nominated);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
this.#armed = armed;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* What one connection's outcome says about the watches it carried.
|
|
1277
|
+
*
|
|
1278
|
+
* The one refusal a websocket cannot report, answered by elimination — and it
|
|
1279
|
+
* has to be an elimination actually carried out rather than a guess dressed
|
|
1280
|
+
* as one. A watch is the only thing on this URL a host refuses outright: a
|
|
1281
|
+
* path it will not honour is a 400 on the UPGRADE and a nomination past the
|
|
1282
|
+
* 32 trees a computer will watch is a 409, and neither status nor body
|
|
1283
|
+
* reaches a websocket client. Left alone, one nomination the host will not
|
|
1284
|
+
* take is the whole stream gone — no windows, no process exits, no readiness,
|
|
1285
|
+
* reconnecting forever with nothing ever saying why.
|
|
1286
|
+
*
|
|
1287
|
+
* But a host that is down fails in exactly the same way, so two missed
|
|
1288
|
+
* handshakes are not proof of anything. The experiment is: drop the newest
|
|
1289
|
+
* tree and try again WITHOUT it. If that connection greets, the tree was the
|
|
1290
|
+
* problem and this server can say so. If it fails too, the tree was innocent
|
|
1291
|
+
* — it goes back, and nothing is claimed about the host beyond its being
|
|
1292
|
+
* unreachable, which the reconnect loop was already handling.
|
|
1293
|
+
*/
|
|
1294
|
+
#cleared() {
|
|
1295
|
+
// Whatever was withheld is now proven guilty: this is the same stream
|
|
1296
|
+
// without it, and it opened.
|
|
1297
|
+
if (this.#shedCandidate !== undefined) {
|
|
1298
|
+
this.#watchRefused.add(this.#shedCandidate);
|
|
1299
|
+
this.#shedCandidate = undefined;
|
|
1300
|
+
}
|
|
1301
|
+
this.#lastGood = [...this.#sent];
|
|
1302
|
+
this.#upgradeFailures = 0;
|
|
1303
|
+
this.#shedRuledOut = false;
|
|
1304
|
+
}
|
|
1305
|
+
/** @see {@link #cleared} — the other half, for a connection that never greeted. */
|
|
1306
|
+
#blamed() {
|
|
1307
|
+
if (this.#shedCandidate !== undefined) {
|
|
1308
|
+
// The experiment came back negative. The tree goes back on the URL and
|
|
1309
|
+
// watches stop being blamed until something connects — otherwise a host
|
|
1310
|
+
// that is simply down would shed its way through every tree in the set,
|
|
1311
|
+
// reporting each in turn as one the host would not carry.
|
|
1312
|
+
this.#shedCandidate = undefined;
|
|
1313
|
+
this.#shedRuledOut = true;
|
|
1314
|
+
this.#upgradeFailures = 0;
|
|
1315
|
+
this.#wakeAll();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (!this.#sent.length || this.#shedRuledOut)
|
|
1319
|
+
return;
|
|
1320
|
+
if (++this.#upgradeFailures < WATCH_SHED_AFTER)
|
|
1321
|
+
return;
|
|
1322
|
+
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
|
+
const carried = this.#sent;
|
|
1329
|
+
this.#shedCandidate =
|
|
1330
|
+
carried.find((w) => !this.#lastGood.includes(w)) ?? carried[carried.length - 1];
|
|
1331
|
+
this.#wakeAll();
|
|
1332
|
+
}
|
|
1333
|
+
#bumpArm(path) {
|
|
1334
|
+
this.#armGen.set(path, (this.#armGen.get(path) ?? 0) + 1);
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* What a `file.changed` says about the TREE, as opposed to about a file.
|
|
1338
|
+
*
|
|
1339
|
+
* Three payload shapes share one type here, and only one of them is a change:
|
|
1340
|
+
* `{watch, path, kind, dir}` is a file, `{watch, armed}` is the tree becoming
|
|
1341
|
+
* live, and `{watch, lost}` is the tree saying this stream's picture of it is
|
|
1342
|
+
* wrong. The last two are state, and are recorded BEFORE the event is pushed
|
|
1343
|
+
* so that a waiter woken by the push reads the state the push is about.
|
|
1344
|
+
*
|
|
1345
|
+
* Only `unwatchable` disarms. `flood` and `budget` both say the tree IS being
|
|
1346
|
+
* watched and is being reported incompletely — treating them as a disarm
|
|
1347
|
+
* would answer the next wait with "this tree is not being watched" about a
|
|
1348
|
+
* tree that is, forever, because nothing would arm it again.
|
|
1349
|
+
*/
|
|
1350
|
+
#onFileFrame(frame) {
|
|
1351
|
+
const data = frame.data;
|
|
1352
|
+
const named = str(data?.watch);
|
|
1353
|
+
if (!named)
|
|
1354
|
+
return;
|
|
1355
|
+
// Back into this client's own spelling, since that is what every nomination
|
|
1356
|
+
// is keyed by here. Ours already in the ordinary case, where the host's
|
|
1357
|
+
// normalisation and this file's agree and the map is empty.
|
|
1358
|
+
const watch = this.#watches.find((w) => this.hostPath(w) === named);
|
|
1359
|
+
if (!watch)
|
|
1360
|
+
return;
|
|
1361
|
+
const lost = str(data?.lost);
|
|
1362
|
+
if (data?.armed === true) {
|
|
1363
|
+
this.#watchLost.delete(watch);
|
|
1364
|
+
// Always a transition, never a restatement, and that is the host's
|
|
1365
|
+
// guarantee rather than an assumption. The guest re-states `armed` for
|
|
1366
|
+
// every tree it is already watching whenever the host nominates anything
|
|
1367
|
+
// — which happens whenever any subscriber on this computer arrives — and
|
|
1368
|
+
// the host DROPS those, delivering one only when its own record says the
|
|
1369
|
+
// tree was not armed. So a frame that gets here means the watch really
|
|
1370
|
+
// was interrupted and is reporting from HERE, and anything that happened
|
|
1371
|
+
// in between was never reported.
|
|
1372
|
+
//
|
|
1373
|
+
// Compared against this client's own idea of armed instead, a re-arm
|
|
1374
|
+
// after a stop and a start would look like a restatement — nothing here
|
|
1375
|
+
// clears the flag when the link goes down — and the wait that should have
|
|
1376
|
+
// said "re-read the tree" would have gone on waiting on a tree whose
|
|
1377
|
+
// history had a hole in it.
|
|
1378
|
+
this.#bumpArm(watch);
|
|
1379
|
+
this.#armed.set(watch, true);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (lost) {
|
|
1383
|
+
// Only the standing ones. `budget` says part of this tree is not being
|
|
1384
|
+
// watched and stays true until a narrower path is nominated; a later wait
|
|
1385
|
+
// on it must not answer "nothing changed" as though the whole tree had
|
|
1386
|
+
// been covered. `unwatchable` says none of it is. A `flood` is neither: it
|
|
1387
|
+
// is a burst that is over, and holding onto one would make every answer
|
|
1388
|
+
// about a tree that ever saw a build hedge for the rest of the session.
|
|
1389
|
+
if (lost === 'budget' || lost === 'unwatchable')
|
|
1390
|
+
this.#watchLost.set(watch, lost);
|
|
1391
|
+
if (lost === 'unwatchable')
|
|
1392
|
+
this.#armed.set(watch, false);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/** The readiness that already happened, in the shape the model is reading. */
|
|
1396
|
+
#pushReady(cursor) {
|
|
1397
|
+
this.#push({
|
|
1398
|
+
type: 'computer.ready',
|
|
1399
|
+
at: new Date().toISOString(),
|
|
1400
|
+
computer: this.computerId,
|
|
1401
|
+
cursor,
|
|
1402
|
+
source: 'daemon',
|
|
1403
|
+
data: {},
|
|
1404
|
+
// Flagged rather than passed off as the real thing, because it is not
|
|
1405
|
+
// one: it has no `seq`, and its `at` is when this server attached rather
|
|
1406
|
+
// than when the desktop came up.
|
|
1407
|
+
synthesized: true,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
#onFrame(frame) {
|
|
1411
|
+
const cursor = str(frame.cursor);
|
|
1412
|
+
if (frame.type === 'gap') {
|
|
1413
|
+
// Never delivered. The model has no documented fallback for a `gap` and
|
|
1414
|
+
// no way to be told one mid-stream, so handing it the frame is handing it
|
|
1415
|
+
// a recovery procedure to invent. What it gets instead is a sentence and,
|
|
1416
|
+
// from the tool, the state it would have gone to reconcile against.
|
|
1417
|
+
const detail = str(frame.detail);
|
|
1418
|
+
this.#loss = {
|
|
1419
|
+
events: null,
|
|
1420
|
+
reason: detail
|
|
1421
|
+
? `the platform could not replay that far: ${detail}`
|
|
1422
|
+
: 'the platform could not replay from where this stream had got to, so some events are gone',
|
|
1423
|
+
};
|
|
1424
|
+
// A gap's own cursor is where the replayable history now starts. Resuming
|
|
1425
|
+
// from it is legal and is what keeps the next reconnect from asking for
|
|
1426
|
+
// the same missing window again.
|
|
1427
|
+
const oldest = str(frame.data?.oldest_cursor);
|
|
1428
|
+
if (cursor)
|
|
1429
|
+
this.#resume = cursor;
|
|
1430
|
+
else if (oldest)
|
|
1431
|
+
this.#resume = oldest;
|
|
1432
|
+
// A gapped resume counts as no continuity, so the readiness `#onHello`
|
|
1433
|
+
// declined to synthesize — because this connection was resuming, and a
|
|
1434
|
+
// resuming connection is about to be handed the backlog — has to be made
|
|
1435
|
+
// here instead. The backlog it would have been in is precisely what the
|
|
1436
|
+
// gap says is gone. Once per gap, and a second one is not a duplicate to
|
|
1437
|
+
// suppress: a display manager restarted inside a running computer is a
|
|
1438
|
+
// new desktop session, and a gap is exactly where the event saying so
|
|
1439
|
+
// went missing (the SDK's OPL-4206).
|
|
1440
|
+
// The floor moves with the gap when the model has been handed nothing
|
|
1441
|
+
// yet: the history before this point is what the gap says is gone, so a
|
|
1442
|
+
// replacement subscription resuming from where this one STARTED would ask
|
|
1443
|
+
// for a window that cannot be replayed and be told so a second time.
|
|
1444
|
+
if (!this.#deliveredCursor)
|
|
1445
|
+
this.#start = this.#resume;
|
|
1446
|
+
// Stamped with the GAP's position, not the opening frame's. A resumed
|
|
1447
|
+
// connection's `hello` carries the cursor it attached at, which can be
|
|
1448
|
+
// older than events the model has already been handed — and a synthesized
|
|
1449
|
+
// event carrying it would walk `#deliveredCursor` backwards, so the next
|
|
1450
|
+
// `since` re-delivered what had already been read, silently, with no gap
|
|
1451
|
+
// to report it.
|
|
1452
|
+
if (this.#hello?.ready)
|
|
1453
|
+
this.#pushReady(this.#resume ?? this.#hello.cursor);
|
|
1454
|
+
this.#wakeAll();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (frame.type === 'capabilities') {
|
|
1458
|
+
// Replaces what `hello` advertised. It goes both ways: a guest that turns
|
|
1459
|
+
// out to have no watcher withdraws the half `hello` promised, and a
|
|
1460
|
+
// computer stopped and started under an open socket can acquire it.
|
|
1461
|
+
const events = list(frame.events);
|
|
1462
|
+
if (events)
|
|
1463
|
+
this.#types = events;
|
|
1464
|
+
this.#wakeAll();
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
// `closed` is this host saying it is ending the socket on purpose rather
|
|
1468
|
+
// than the socket simply dying. Nothing here needs to act on the
|
|
1469
|
+
// difference: the reconnect re-reads the computer either way, and that read
|
|
1470
|
+
// is what tells a machine somebody stopped from one that merely moved.
|
|
1471
|
+
if (STREAM_FRAMES.has(frame.type))
|
|
1472
|
+
return;
|
|
1473
|
+
if (frame.type === 'file.changed')
|
|
1474
|
+
this.#onFileFrame(frame);
|
|
1475
|
+
if (cursor)
|
|
1476
|
+
this.#resume = cursor;
|
|
1477
|
+
this.#push(frame);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Every computer this session is listening to.
|
|
1482
|
+
*
|
|
1483
|
+
* One per session and not one per process, for the reason `Session` itself is:
|
|
1484
|
+
* over the HTTP transport each caller arrives with their own key, and a
|
|
1485
|
+
* process-wide hub would hold one caller's socket open for another caller's
|
|
1486
|
+
* computer.
|
|
1487
|
+
*/
|
|
1488
|
+
export class EventHub {
|
|
1489
|
+
#api;
|
|
1490
|
+
#socketFor;
|
|
1491
|
+
#subs = new Map();
|
|
1492
|
+
/**
|
|
1493
|
+
* Where each computer's stream had got to when its subscription went away,
|
|
1494
|
+
* and what it was watching.
|
|
1495
|
+
*
|
|
1496
|
+
* A reap is not a decision to forget. Five minutes without a tool call is an
|
|
1497
|
+
* ordinary thing for a model to do — a long `exec`, a detour onto another
|
|
1498
|
+
* machine — and a subscription that reopened at the head afterwards would
|
|
1499
|
+
* lose exactly the `process.exited` the detour was waiting on. Reopening with
|
|
1500
|
+
* this asks the platform to replay from there instead, and where it cannot,
|
|
1501
|
+
* the answer is an honest gap rather than silence.
|
|
1502
|
+
*
|
|
1503
|
+
* The nominations ride with the cursor because they have the same shape of
|
|
1504
|
+
* consequence and a worse failure. A tree is watched by the CONNECTION, so a
|
|
1505
|
+
* reap ends every watch on that computer — and a stream that came back
|
|
1506
|
+
* watching nothing would answer the next question about a tree with a
|
|
1507
|
+
* perfectly ordinary "nothing changed", over a window during which nothing
|
|
1508
|
+
* was looking. One map rather than two, so an entry is evicted whole.
|
|
1509
|
+
*/
|
|
1510
|
+
#memory = new Map();
|
|
1511
|
+
/** Bound, so a session that touches thousands of computers cannot grow forever. */
|
|
1512
|
+
static #MAX_REMEMBERED = 256;
|
|
1513
|
+
#sweep;
|
|
1514
|
+
constructor(api, socketFor = defaultEventSocket) {
|
|
1515
|
+
this.#api = api;
|
|
1516
|
+
this.#socketFor = socketFor;
|
|
1517
|
+
}
|
|
1518
|
+
/** This computer's subscription, opened if this is the first ask. */
|
|
1519
|
+
open(computerId) {
|
|
1520
|
+
let sub = this.#subs.get(computerId);
|
|
1521
|
+
if (!sub) {
|
|
1522
|
+
const held = this.#memory.get(computerId);
|
|
1523
|
+
sub = new Subscription(this.#api, computerId, this.#socketFor, held?.cursor, held?.watches);
|
|
1524
|
+
this.#subs.set(computerId, sub);
|
|
1525
|
+
sub.start();
|
|
1526
|
+
this.#startSweep();
|
|
1527
|
+
}
|
|
1528
|
+
sub.touch();
|
|
1529
|
+
return sub;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Drop one, so the next ask opens a fresh socket and a fresh buffer.
|
|
1533
|
+
*
|
|
1534
|
+
* `remember` is the difference between a stream this session may want to
|
|
1535
|
+
* resume — reaped for idleness, stopped on a computer somebody suspended —
|
|
1536
|
+
* and one there is nothing left to resume: a deleted computer's cursor names
|
|
1537
|
+
* a position in a stream that no longer exists.
|
|
1538
|
+
*/
|
|
1539
|
+
drop(computerId, reason, remember = false) {
|
|
1540
|
+
const sub = this.#subs.get(computerId);
|
|
1541
|
+
if (!sub)
|
|
1542
|
+
return;
|
|
1543
|
+
const at = sub.resumeCursor;
|
|
1544
|
+
const watches = sub.nominations;
|
|
1545
|
+
sub.close(reason);
|
|
1546
|
+
this.#subs.delete(computerId);
|
|
1547
|
+
if (remember && (at || watches.length)) {
|
|
1548
|
+
// Delete before set so a repeated id is the newest entry and the oldest
|
|
1549
|
+
// is the one evicted.
|
|
1550
|
+
this.#memory.delete(computerId);
|
|
1551
|
+
this.#memory.set(computerId, { cursor: at, watches });
|
|
1552
|
+
for (const key of this.#memory.keys()) {
|
|
1553
|
+
if (this.#memory.size <= EventHub.#MAX_REMEMBERED)
|
|
1554
|
+
break;
|
|
1555
|
+
this.#memory.delete(key);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1559
|
+
this.#memory.delete(computerId);
|
|
1560
|
+
}
|
|
1561
|
+
if (!this.#subs.size)
|
|
1562
|
+
this.#stopSweep();
|
|
1563
|
+
}
|
|
1564
|
+
/** Close every socket. The session is over, or the process is going away. */
|
|
1565
|
+
closeAll() {
|
|
1566
|
+
for (const [id, sub] of this.#subs) {
|
|
1567
|
+
sub.close('the session ended');
|
|
1568
|
+
this.#subs.delete(id);
|
|
1569
|
+
}
|
|
1570
|
+
this.#memory.clear();
|
|
1571
|
+
this.#stopSweep();
|
|
1572
|
+
}
|
|
1573
|
+
#startSweep() {
|
|
1574
|
+
if (this.#sweep)
|
|
1575
|
+
return;
|
|
1576
|
+
this.#sweep = setInterval(() => {
|
|
1577
|
+
for (const [id, sub] of this.#subs) {
|
|
1578
|
+
// A stopped subscription is kept until something asks about it, so the
|
|
1579
|
+
// reason it stopped — suspended, deleted, Windows — is still there to
|
|
1580
|
+
// be reported once. The idle window is what eventually takes it.
|
|
1581
|
+
if (sub.idleMs < IDLE_REAP_MS)
|
|
1582
|
+
continue;
|
|
1583
|
+
this.drop(id, 'nothing asked about this computer for five minutes', true);
|
|
1584
|
+
}
|
|
1585
|
+
if (!this.#subs.size)
|
|
1586
|
+
this.#stopSweep();
|
|
1587
|
+
}, SWEEP_MS);
|
|
1588
|
+
// A timer is not a reason for a process to stay alive. Without this an
|
|
1589
|
+
// stdio server whose client has gone away, and every test that ever opened
|
|
1590
|
+
// a stream, hangs at exit until the sweep is cleared by hand.
|
|
1591
|
+
this.#sweep.unref?.();
|
|
1592
|
+
}
|
|
1593
|
+
#stopSweep() {
|
|
1594
|
+
if (!this.#sweep)
|
|
1595
|
+
return;
|
|
1596
|
+
clearInterval(this.#sweep);
|
|
1597
|
+
this.#sweep = undefined;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* A failure that answers the same way however often it is asked.
|
|
1602
|
+
*
|
|
1603
|
+
* The reconnect loop's one branch: everything else is weather and is backed
|
|
1604
|
+
* off, and this ends the stream with the sentence saying why.
|
|
1605
|
+
*/
|
|
1606
|
+
class SettledError extends MandalaError {
|
|
1607
|
+
}
|
|
1608
|
+
/** One text frame, as an object, or `undefined` for anything that is not one. */
|
|
1609
|
+
function parse(data) {
|
|
1610
|
+
const text = typeof data === 'string'
|
|
1611
|
+
? data
|
|
1612
|
+
: data instanceof Uint8Array
|
|
1613
|
+
? new TextDecoder().decode(data)
|
|
1614
|
+
: undefined;
|
|
1615
|
+
if (text === undefined)
|
|
1616
|
+
return undefined;
|
|
1617
|
+
let frame;
|
|
1618
|
+
try {
|
|
1619
|
+
frame = JSON.parse(text);
|
|
1620
|
+
}
|
|
1621
|
+
catch {
|
|
1622
|
+
return undefined;
|
|
1623
|
+
}
|
|
1624
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame))
|
|
1625
|
+
return undefined;
|
|
1626
|
+
const type = frame.type;
|
|
1627
|
+
if (typeof type !== 'string' || !type)
|
|
1628
|
+
return undefined;
|
|
1629
|
+
return frame;
|
|
1630
|
+
}
|
|
1631
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
1632
|
+
const list = (v) => Array.isArray(v) ? v.filter((e) => typeof e === 'string') : undefined;
|
|
1633
|
+
/**
|
|
1634
|
+
* `hello.watching`, or `undefined` for a frame that carried none.
|
|
1635
|
+
*
|
|
1636
|
+
* `armed` is read as TRUE ONLY, on the same reasoning as `ready` above: an
|
|
1637
|
+
* armedness nobody claimed is one to wait for, and waiting on a tree that is
|
|
1638
|
+
* already live ends when the next thing happens under it. Concluding a tree is
|
|
1639
|
+
* live because a field was malformed is the unrecoverable half — it hands a
|
|
1640
|
+
* model a silence it will read as "nothing changed".
|
|
1641
|
+
*/
|
|
1642
|
+
const watched = (v) => {
|
|
1643
|
+
if (!Array.isArray(v))
|
|
1644
|
+
return undefined;
|
|
1645
|
+
const out = [];
|
|
1646
|
+
for (const e of v) {
|
|
1647
|
+
if (!e || typeof e !== 'object')
|
|
1648
|
+
continue;
|
|
1649
|
+
const path = str(e.path);
|
|
1650
|
+
if (!path)
|
|
1651
|
+
continue;
|
|
1652
|
+
out.push({ path, armed: e.armed === true });
|
|
1653
|
+
}
|
|
1654
|
+
return out;
|
|
1655
|
+
};
|
|
1656
|
+
/**
|
|
1657
|
+
* A pause that ends early when the stream is closed.
|
|
1658
|
+
*
|
|
1659
|
+
* `unref`'d for the reason the idle sweep is: a timer is not a reason for a
|
|
1660
|
+
* process to stay alive. The reconnect backoff is the one timer a subscription
|
|
1661
|
+
* holds while it has no socket, and a ref'd one kept an stdio server up after
|
|
1662
|
+
* its client had gone. Nothing is lost by it — a client that is still attached
|
|
1663
|
+
* holds stdin, which keeps the loop running on its own — and it is a backstop
|
|
1664
|
+
* rather than the fix: `runStdio` closes the session on stdin EOF, and this is
|
|
1665
|
+
* what covers the window where the socket is already down.
|
|
1666
|
+
*/
|
|
1667
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
1668
|
+
if (signal.aborted || ms <= 0)
|
|
1669
|
+
return resolve();
|
|
1670
|
+
const t = setTimeout(done, ms);
|
|
1671
|
+
t.unref?.();
|
|
1672
|
+
function done() {
|
|
1673
|
+
clearTimeout(t);
|
|
1674
|
+
signal.removeEventListener('abort', done);
|
|
1675
|
+
resolve();
|
|
1676
|
+
}
|
|
1677
|
+
signal.addEventListener('abort', done, { once: true });
|
|
1678
|
+
});
|
|
1679
|
+
//# sourceMappingURL=events.js.map
|