@vibecook/ghosttea-react 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/dist/TerminalSurface.d.ts +5 -0
- package/dist/TerminalSurface.d.ts.map +1 -1
- package/dist/TerminalSurface.js +19 -4
- package/dist/TerminalSurface.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/performance.d.ts +31 -0
- package/dist/performance.d.ts.map +1 -1
- package/dist/performance.js.map +1 -1
- package/dist/routed-activation.d.ts +110 -0
- package/dist/routed-activation.d.ts.map +1 -0
- package/dist/routed-activation.js +287 -0
- package/dist/routed-activation.js.map +1 -0
- package/dist/routed-control.d.ts +79 -0
- package/dist/routed-control.d.ts.map +1 -0
- package/dist/routed-control.js +397 -0
- package/dist/routed-control.js.map +1 -0
- package/dist/routed-frames.d.ts +69 -0
- package/dist/routed-frames.d.ts.map +1 -0
- package/dist/routed-frames.js +660 -0
- package/dist/routed-frames.js.map +1 -0
- package/dist/runtime.d.ts +96 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1227 -100
- package/dist/runtime.js.map +1 -1
- package/dist/terminal-render.worker.js +1089 -45
- package/dist/terminal-render.worker.js.map +3 -3
- package/dist/worker-messages.d.ts +18 -1
- package/dist/worker-messages.d.ts.map +1 -1
- package/dist/workspace/Workspace.d.ts +12 -1
- package/dist/workspace/Workspace.d.ts.map +1 -1
- package/dist/workspace/Workspace.js +77 -14
- package/dist/workspace/Workspace.js.map +1 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js.map +1 -1
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { ControlClient } from "@vibecook/ghosttea";
|
|
2
|
-
import { PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, isValidScrollbackBytes, } from "@vibecook/ghosttea-protocol";
|
|
2
|
+
import { DEFAULT_ROUTED_PROTOCOL_LIMITS, PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, isRoutedSessionAttachGrant, isRoutedTerminalOpenTicket, isValidScrollbackBytes, } from "@vibecook/ghosttea-protocol";
|
|
3
3
|
import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame";
|
|
4
4
|
import { FrameResyncController } from "./frame-resync.js";
|
|
5
|
+
import { initialRoutedActivation, reduceRoutedActivation, } from "./routed-activation.js";
|
|
6
|
+
import { RoutedControlTransport, } from "./routed-control.js";
|
|
7
|
+
const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647;
|
|
8
|
+
function routedConnectionRefusalIsRecoverable(refusal) {
|
|
9
|
+
return refusal.retryable || refusal.code === "GRANT_GENERATION_ROLLBACK" || refusal.code === "GRANT_NONCE_REPLAYED";
|
|
10
|
+
}
|
|
5
11
|
function sameSessionActivity(left, right) {
|
|
6
12
|
return (left.kind === right.kind &&
|
|
7
13
|
left.source === right.source &&
|
|
@@ -47,6 +53,14 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
|
|
|
47
53
|
export class GhostteaTerminalRuntime extends EventTarget {
|
|
48
54
|
#worker;
|
|
49
55
|
#ports;
|
|
56
|
+
#routedHost;
|
|
57
|
+
#routedReceiverCapacities;
|
|
58
|
+
#routedCapabilities;
|
|
59
|
+
#routedControl;
|
|
60
|
+
#routedBySession = new Map();
|
|
61
|
+
#routedByActivation = new Map();
|
|
62
|
+
#routedGeometry = new Map();
|
|
63
|
+
#routedAttachDeadlineByCell = new Map();
|
|
50
64
|
#platform;
|
|
51
65
|
#clientBuild;
|
|
52
66
|
#sessionOwnerId;
|
|
@@ -87,16 +101,33 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
87
101
|
#configSnapshot;
|
|
88
102
|
#configProtocolSupported = false;
|
|
89
103
|
#metadataTimers = new Map();
|
|
104
|
+
#metadataRefreshes = new Map();
|
|
105
|
+
#metadataRefreshPending = new Set();
|
|
106
|
+
#sessionGenerationByHandle = new Map();
|
|
107
|
+
#appliedRoutedExitEvents = new Set();
|
|
108
|
+
#sessionGeneration = 0;
|
|
90
109
|
#resync;
|
|
91
110
|
#performanceRequestId = 1;
|
|
92
111
|
#performanceRequests = new Map();
|
|
112
|
+
#counterRequests = new Map();
|
|
93
113
|
#disposed = false;
|
|
94
114
|
constructor(options) {
|
|
95
115
|
super();
|
|
96
116
|
this.#worker =
|
|
97
117
|
options.workerFactory?.() ??
|
|
98
118
|
new Worker(new URL("./terminal-render.worker.js", import.meta.url), { type: "module" });
|
|
99
|
-
this.#ports = Promise.resolve(options.ports);
|
|
119
|
+
this.#ports = options.transport === "routed" ? undefined : Promise.resolve(options.ports);
|
|
120
|
+
this.#routedHost = options.transport === "routed" ? options.host : undefined;
|
|
121
|
+
this.#routedReceiverCapacities = options.transport === "routed" ? options.receiverCapacities : undefined;
|
|
122
|
+
this.#routedCapabilities = options.transport === "routed" ? (options.capabilities ?? ["resume"]) : [];
|
|
123
|
+
this.#routedControl =
|
|
124
|
+
options.transport === "routed"
|
|
125
|
+
? new RoutedControlTransport({
|
|
126
|
+
...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }),
|
|
127
|
+
acceptExtensionMessages: options.host.onExtensionMessage !== undefined,
|
|
128
|
+
emit: (event) => this.#handleRoutedControlEvent(event),
|
|
129
|
+
})
|
|
130
|
+
: undefined;
|
|
100
131
|
this.#platform = options.platform;
|
|
101
132
|
this.#clientBuild = options.clientBuild ?? "ghosttea-react";
|
|
102
133
|
this.#sessionOwnerId = options.sessionOwnerId;
|
|
@@ -137,6 +168,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
137
168
|
this.#resync.complete(data.sessionHandle);
|
|
138
169
|
}
|
|
139
170
|
else if (data.type === "frame-committed") {
|
|
171
|
+
if (this.#routedHost?.getSession)
|
|
172
|
+
this.#scheduleMetadataRefresh(data.sessionHandle);
|
|
140
173
|
this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
|
|
141
174
|
}
|
|
142
175
|
else if (data.type === "catalog-pressure") {
|
|
@@ -152,6 +185,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
152
185
|
else if (data.type === "performance-result") {
|
|
153
186
|
this.#resolvePerformanceRequest(data.requestId, data.snapshot);
|
|
154
187
|
}
|
|
188
|
+
else if (data.type === "performance-counters") {
|
|
189
|
+
const pending = this.#counterRequests.get(data.requestId);
|
|
190
|
+
if (!pending)
|
|
191
|
+
return;
|
|
192
|
+
window.clearTimeout(pending.timer);
|
|
193
|
+
this.#counterRequests.delete(data.requestId);
|
|
194
|
+
pending.resolve(data.snapshot);
|
|
195
|
+
}
|
|
196
|
+
else if (data.type === "routed-frames-event") {
|
|
197
|
+
this.#handleRoutedFramesEvent(data.event);
|
|
198
|
+
}
|
|
155
199
|
else if (data.type === "renderer-reload-required") {
|
|
156
200
|
console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
|
|
157
201
|
this.#platform.setForceCanvasFallback(true);
|
|
@@ -171,6 +215,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
171
215
|
get rendererBackend() {
|
|
172
216
|
return this.#rendererBackend;
|
|
173
217
|
}
|
|
218
|
+
/** Current main-authority state for a routed session. */
|
|
219
|
+
routedActivation(sessionId) {
|
|
220
|
+
return this.#routedBySession.get(sessionId)?.state;
|
|
221
|
+
}
|
|
222
|
+
routedViewInputAllowed(viewId) {
|
|
223
|
+
const view = this.#views.get(viewId);
|
|
224
|
+
if (!view?.clientReadWrite || view.readWrite === false)
|
|
225
|
+
return false;
|
|
226
|
+
return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
|
|
227
|
+
}
|
|
174
228
|
#resolvePerformanceRequest(requestId, value) {
|
|
175
229
|
const pending = this.#performanceRequests.get(requestId);
|
|
176
230
|
if (!pending)
|
|
@@ -203,6 +257,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
203
257
|
throw new Error("Terminal render worker returned no performance snapshot");
|
|
204
258
|
return result;
|
|
205
259
|
}
|
|
260
|
+
/** Reads monotonic production counters without starting a sample window or draining the GPU. */
|
|
261
|
+
readPerformanceCounters(timeoutMs = 2_000) {
|
|
262
|
+
if (this.#disposed)
|
|
263
|
+
return Promise.reject(new Error("Terminal runtime is disposed"));
|
|
264
|
+
const requestId = this.#performanceRequestId++;
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const timer = window.setTimeout(() => {
|
|
267
|
+
this.#counterRequests.delete(requestId);
|
|
268
|
+
reject(new Error(`Terminal render counter request ${requestId} timed out`));
|
|
269
|
+
}, timeoutMs);
|
|
270
|
+
this.#counterRequests.set(requestId, { resolve, reject, timer });
|
|
271
|
+
this.#postWorker({ type: "performance-counters", requestId });
|
|
272
|
+
});
|
|
273
|
+
}
|
|
206
274
|
connect() {
|
|
207
275
|
if (this.#disposed)
|
|
208
276
|
return Promise.reject(new Error("Terminal runtime is disposed"));
|
|
@@ -210,6 +278,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
210
278
|
return this.#ready;
|
|
211
279
|
}
|
|
212
280
|
async #connect() {
|
|
281
|
+
if (this.#routedHost)
|
|
282
|
+
return;
|
|
213
283
|
const ports = await this.#ports;
|
|
214
284
|
if (this.#disposed) {
|
|
215
285
|
ports.control.close();
|
|
@@ -220,22 +290,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
220
290
|
this.#control = new ControlClient(ports.control);
|
|
221
291
|
this.#control.addEventListener("session-exited", (event) => {
|
|
222
292
|
const detail = event.detail;
|
|
223
|
-
|
|
224
|
-
const session = handle ? this.#sessionByHandle.get(handle) : undefined;
|
|
225
|
-
if (!handle || !session)
|
|
226
|
-
return;
|
|
227
|
-
this.#cancelMetadataRefresh(handle);
|
|
228
|
-
const exited = {
|
|
229
|
-
...session,
|
|
230
|
-
exited: true,
|
|
231
|
-
exitCode: detail.exitCode,
|
|
232
|
-
exitSignal: detail.exitSignal,
|
|
233
|
-
requestedTermination: detail.requestedTermination,
|
|
234
|
-
exitOutcome: detail.exitOutcome,
|
|
235
|
-
};
|
|
236
|
-
this.#sessionByHandle.set(handle, exited);
|
|
237
|
-
this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
|
|
238
|
-
this.dispatchEvent(new CustomEvent("session-exited", { detail }));
|
|
293
|
+
this.#applySessionExited(detail);
|
|
239
294
|
});
|
|
240
295
|
this.#control.addEventListener("events-lost", () => {
|
|
241
296
|
// The daemon dropped events faster than this client drained them. Any
|
|
@@ -245,14 +300,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
245
300
|
});
|
|
246
301
|
this.#control.addEventListener("session-activity-changed", (event) => {
|
|
247
302
|
const detail = event.detail;
|
|
248
|
-
|
|
249
|
-
const session = handle ? this.#sessionByHandle.get(handle) : undefined;
|
|
250
|
-
if (!handle || !session || session.exited)
|
|
251
|
-
return;
|
|
252
|
-
const updated = { ...session, activity: detail.activity };
|
|
253
|
-
this.#sessionByHandle.set(handle, updated);
|
|
254
|
-
this.dispatchEvent(new CustomEvent("session-activity", { detail }));
|
|
255
|
-
this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
|
|
303
|
+
this.#applySessionActivity(detail);
|
|
256
304
|
});
|
|
257
305
|
this.#control.addEventListener("control-changed", (event) => {
|
|
258
306
|
const detail = event.detail;
|
|
@@ -342,6 +390,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
342
390
|
}
|
|
343
391
|
async reloadConfig() {
|
|
344
392
|
await this.connect();
|
|
393
|
+
if (this.#routedHost)
|
|
394
|
+
throw new Error("Configuration reload is not part of the routed host contract");
|
|
345
395
|
const response = await this.#control.request({ type: "reload-config" });
|
|
346
396
|
if (response.type !== "config")
|
|
347
397
|
throw new Error("ghosttead returned an unexpected configuration response");
|
|
@@ -400,54 +450,196 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
400
450
|
if (this.#frameFlowControlEnabled)
|
|
401
451
|
this.#frames?.postMessage({ type: "frame-credit", bytes });
|
|
402
452
|
}
|
|
453
|
+
#bumpSessionGeneration(sessionHandle) {
|
|
454
|
+
this.#sessionGeneration += 1;
|
|
455
|
+
this.#sessionGenerationByHandle.set(sessionHandle, this.#sessionGeneration);
|
|
456
|
+
return this.#sessionGeneration;
|
|
457
|
+
}
|
|
458
|
+
#applySessionExited(detail) {
|
|
459
|
+
const handle = this.#handleBySessionId.get(detail.sessionId);
|
|
460
|
+
const session = handle ? this.#sessionByHandle.get(handle) : undefined;
|
|
461
|
+
if (!handle || !session)
|
|
462
|
+
return;
|
|
463
|
+
this.#cancelMetadataRefresh(handle);
|
|
464
|
+
const exited = {
|
|
465
|
+
...session,
|
|
466
|
+
exited: true,
|
|
467
|
+
exitCode: detail.exitCode,
|
|
468
|
+
exitSignal: detail.exitSignal,
|
|
469
|
+
requestedTermination: detail.requestedTermination,
|
|
470
|
+
exitOutcome: detail.exitOutcome,
|
|
471
|
+
};
|
|
472
|
+
this.#sessionByHandle.set(handle, exited);
|
|
473
|
+
this.#bumpSessionGeneration(handle);
|
|
474
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
|
|
475
|
+
this.dispatchEvent(new CustomEvent("session-exited", { detail }));
|
|
476
|
+
}
|
|
477
|
+
#applySessionActivity(detail) {
|
|
478
|
+
const handle = this.#handleBySessionId.get(detail.sessionId);
|
|
479
|
+
const session = handle ? this.#sessionByHandle.get(handle) : undefined;
|
|
480
|
+
if (!handle || !session || session.exited)
|
|
481
|
+
return;
|
|
482
|
+
const updated = { ...session, activity: detail.activity };
|
|
483
|
+
this.#sessionByHandle.set(handle, updated);
|
|
484
|
+
this.#bumpSessionGeneration(handle);
|
|
485
|
+
this.dispatchEvent(new CustomEvent("session-activity", { detail }));
|
|
486
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
|
|
487
|
+
}
|
|
488
|
+
#applyRoutedSessionSummary(sessionHandle, next, expectedGeneration) {
|
|
489
|
+
const previous = this.#sessionByHandle.get(sessionHandle);
|
|
490
|
+
if (!previous ||
|
|
491
|
+
previous.exited ||
|
|
492
|
+
previous.id !== next.id ||
|
|
493
|
+
next.handle !== sessionHandle ||
|
|
494
|
+
this.#handleBySessionId.get(next.id) !== sessionHandle ||
|
|
495
|
+
(expectedGeneration !== undefined && this.#sessionGenerationByHandle.get(sessionHandle) !== expectedGeneration)) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
this.#sessionByHandle.set(sessionHandle, next);
|
|
499
|
+
this.#bumpSessionGeneration(sessionHandle);
|
|
500
|
+
if (previous.title !== next.title ||
|
|
501
|
+
previous.cwd !== next.cwd ||
|
|
502
|
+
previous.exited !== next.exited ||
|
|
503
|
+
!sameSessionActivity(previous.activity, next.activity)) {
|
|
504
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: next }));
|
|
505
|
+
}
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
403
508
|
#scheduleMetadataRefresh(sessionHandle) {
|
|
404
509
|
const scheduledSession = this.#sessionByHandle.get(sessionHandle);
|
|
405
510
|
if (!scheduledSession || scheduledSession.exited)
|
|
406
511
|
return;
|
|
512
|
+
if (this.#routedHost?.getSession && this.#metadataRefreshes.has(sessionHandle)) {
|
|
513
|
+
this.#metadataRefreshPending.add(sessionHandle);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
407
516
|
if (this.#metadataTimers.has(sessionHandle))
|
|
408
517
|
return;
|
|
409
518
|
const timer = window.setTimeout(() => {
|
|
410
519
|
this.#metadataTimers.delete(sessionHandle);
|
|
411
520
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
412
|
-
if (!session || session.exited
|
|
521
|
+
if (!session || session.exited)
|
|
413
522
|
return;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
previous.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
523
|
+
if (this.#control) {
|
|
524
|
+
void this.#control
|
|
525
|
+
.request({ type: "get-session", sessionId: session.id })
|
|
526
|
+
.then((response) => {
|
|
527
|
+
if (response.type !== "session")
|
|
528
|
+
return;
|
|
529
|
+
const previous = this.#sessionByHandle.get(sessionHandle);
|
|
530
|
+
if (!previous || previous.id !== response.session.id || previous.exited)
|
|
531
|
+
return;
|
|
532
|
+
this.#sessionByHandle.set(sessionHandle, response.session);
|
|
533
|
+
if (previous.title !== response.session.title ||
|
|
534
|
+
previous.cwd !== response.session.cwd ||
|
|
535
|
+
previous.exited !== response.session.exited ||
|
|
536
|
+
!sameSessionActivity(previous.activity, response.session.activity)) {
|
|
537
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session }));
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.catch((error) => {
|
|
541
|
+
const current = this.#sessionByHandle.get(sessionHandle);
|
|
542
|
+
if (!current || current.exited || this.#disposed)
|
|
543
|
+
return;
|
|
544
|
+
console.warn("[terminal-runtime] session metadata refresh failed", error);
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (this.#routedHost?.getSession)
|
|
549
|
+
this.#startRoutedMetadataRefresh(sessionHandle, session);
|
|
436
550
|
}, 200);
|
|
437
551
|
this.#metadataTimers.set(sessionHandle, timer);
|
|
438
552
|
}
|
|
553
|
+
#startRoutedMetadataRefresh(sessionHandle, session) {
|
|
554
|
+
const host = this.#routedHost;
|
|
555
|
+
const getSession = host?.getSession;
|
|
556
|
+
if (!host || !getSession || this.#disposed)
|
|
557
|
+
return;
|
|
558
|
+
if (this.#metadataRefreshes.has(sessionHandle)) {
|
|
559
|
+
this.#metadataRefreshPending.add(sessionHandle);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const expectedGeneration = this.#sessionGenerationByHandle.get(sessionHandle);
|
|
563
|
+
const refresh = Promise.resolve()
|
|
564
|
+
.then(() => getSession.call(host, session.id))
|
|
565
|
+
.then((next) => {
|
|
566
|
+
if (next === null)
|
|
567
|
+
return;
|
|
568
|
+
this.#applyRoutedSessionSummary(sessionHandle, next, expectedGeneration);
|
|
569
|
+
})
|
|
570
|
+
.catch((error) => {
|
|
571
|
+
const current = this.#sessionByHandle.get(sessionHandle);
|
|
572
|
+
if (!current || current.exited || this.#disposed)
|
|
573
|
+
return;
|
|
574
|
+
console.warn("[terminal-runtime] session metadata refresh failed", error);
|
|
575
|
+
})
|
|
576
|
+
.finally(() => {
|
|
577
|
+
if (this.#metadataRefreshes.get(sessionHandle) !== refresh)
|
|
578
|
+
return;
|
|
579
|
+
this.#metadataRefreshes.delete(sessionHandle);
|
|
580
|
+
if (!this.#metadataRefreshPending.delete(sessionHandle) || this.#disposed)
|
|
581
|
+
return;
|
|
582
|
+
const current = this.#sessionByHandle.get(sessionHandle);
|
|
583
|
+
if (current && !current.exited)
|
|
584
|
+
this.#scheduleMetadataRefresh(sessionHandle);
|
|
585
|
+
});
|
|
586
|
+
this.#metadataRefreshes.set(sessionHandle, refresh);
|
|
587
|
+
}
|
|
439
588
|
#cancelMetadataRefresh(sessionHandle) {
|
|
440
589
|
const timer = this.#metadataTimers.get(sessionHandle);
|
|
441
590
|
if (timer !== undefined)
|
|
442
591
|
window.clearTimeout(timer);
|
|
443
592
|
this.#metadataTimers.delete(sessionHandle);
|
|
593
|
+
this.#metadataRefreshPending.delete(sessionHandle);
|
|
444
594
|
}
|
|
445
595
|
sessionMetadata(sessionHandle) {
|
|
446
596
|
return this.#sessionByHandle.get(sessionHandle);
|
|
447
597
|
}
|
|
448
598
|
registerSession(session) {
|
|
599
|
+
const previous = this.#sessionByHandle.get(session.handle);
|
|
600
|
+
if (!previous || previous.id !== session.id || !previous.exited || !session.exited) {
|
|
601
|
+
this.#appliedRoutedExitEvents.delete(session.handle);
|
|
602
|
+
}
|
|
449
603
|
this.#sessionByHandle.set(session.handle, session);
|
|
450
604
|
this.#handleBySessionId.set(session.id, session.handle);
|
|
605
|
+
this.#bumpSessionGeneration(session.handle);
|
|
606
|
+
}
|
|
607
|
+
applySessionEvent(event) {
|
|
608
|
+
if (this.#disposed)
|
|
609
|
+
return;
|
|
610
|
+
if (event.type === "updated") {
|
|
611
|
+
const handle = this.#handleBySessionId.get(event.session.id);
|
|
612
|
+
if (handle)
|
|
613
|
+
this.#applyRoutedSessionSummary(handle, event.session);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (event.type === "activity-changed") {
|
|
617
|
+
this.#applySessionActivity({
|
|
618
|
+
requestId: 0,
|
|
619
|
+
type: "session-activity-changed",
|
|
620
|
+
sessionId: event.sessionId,
|
|
621
|
+
activity: event.activity,
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (event.type === "exited") {
|
|
626
|
+
const handle = this.#handleBySessionId.get(event.sessionId);
|
|
627
|
+
const current = handle ? this.#sessionByHandle.get(handle) : undefined;
|
|
628
|
+
if (!handle || !current || this.#appliedRoutedExitEvents.has(handle))
|
|
629
|
+
return;
|
|
630
|
+
this.#appliedRoutedExitEvents.add(handle);
|
|
631
|
+
this.#applySessionExited({
|
|
632
|
+
requestId: 0,
|
|
633
|
+
type: "session-exited",
|
|
634
|
+
sessionId: event.sessionId,
|
|
635
|
+
exitCode: event.exitCode,
|
|
636
|
+
exitSignal: event.exitSignal,
|
|
637
|
+
requestedTermination: event.requestedTermination,
|
|
638
|
+
exitOutcome: event.exitOutcome,
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.unregisterSession(event.sessionId);
|
|
451
643
|
}
|
|
452
644
|
#queueFrameSubscriptionSync() {
|
|
453
645
|
const frames = this.#frames;
|
|
@@ -608,6 +800,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
608
800
|
}
|
|
609
801
|
async createSession(options) {
|
|
610
802
|
await this.connect();
|
|
803
|
+
if (this.#routedHost) {
|
|
804
|
+
if (!this.#routedHost.createSession)
|
|
805
|
+
throw new Error("The routed host does not provide session creation");
|
|
806
|
+
const session = await this.#routedHost.createSession(options);
|
|
807
|
+
this.registerSession(session);
|
|
808
|
+
return session;
|
|
809
|
+
}
|
|
611
810
|
if (options.scrollbackBytes !== undefined) {
|
|
612
811
|
if (!isValidScrollbackBytes(options.scrollbackBytes)) {
|
|
613
812
|
throw new RangeError("scrollbackBytes must be a non-negative safe integer");
|
|
@@ -628,6 +827,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
628
827
|
}
|
|
629
828
|
async listSessions() {
|
|
630
829
|
await this.connect();
|
|
830
|
+
if (this.#routedHost) {
|
|
831
|
+
const sessions = this.#routedHost.listSessions
|
|
832
|
+
? await this.#routedHost.listSessions()
|
|
833
|
+
: [...this.#sessionByHandle.values()];
|
|
834
|
+
for (const session of sessions)
|
|
835
|
+
this.registerSession(session);
|
|
836
|
+
return sessions;
|
|
837
|
+
}
|
|
631
838
|
const response = await this.#control.request({ type: "list-sessions" });
|
|
632
839
|
if (response.type !== "sessions")
|
|
633
840
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -706,6 +913,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
706
913
|
}
|
|
707
914
|
async listRemoteHosts() {
|
|
708
915
|
await this.connect();
|
|
916
|
+
if (this.#routedHost)
|
|
917
|
+
throw new Error("Remote-host discovery is not part of the routed host contract");
|
|
709
918
|
const response = await this.#control.request({ type: "list-remote-hosts" });
|
|
710
919
|
if (response.type !== "remote-hosts")
|
|
711
920
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -713,6 +922,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
713
922
|
}
|
|
714
923
|
async listRemoteSessions(deviceId) {
|
|
715
924
|
await this.connect();
|
|
925
|
+
if (this.#routedHost)
|
|
926
|
+
throw new Error("Remote-session discovery is not part of the routed host contract");
|
|
716
927
|
const response = await this.#control.request({ type: "list-remote-sessions", deviceId }, 35_000);
|
|
717
928
|
if (response.type !== "remote-sessions" || response.deviceId !== deviceId)
|
|
718
929
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -720,6 +931,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
720
931
|
}
|
|
721
932
|
async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
|
|
722
933
|
await this.connect();
|
|
934
|
+
if (this.#routedHost)
|
|
935
|
+
throw new Error("Remote-session opening is not part of the routed host contract");
|
|
723
936
|
const response = await this.#control.request({
|
|
724
937
|
type: "open-remote-session",
|
|
725
938
|
deviceId,
|
|
@@ -767,6 +980,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
767
980
|
mount(sessionId, sessionHandle, viewId, canvas) {
|
|
768
981
|
if (this.#disposed)
|
|
769
982
|
throw new Error("Cannot mount a disposed terminal runtime");
|
|
983
|
+
if (this.#routedHost)
|
|
984
|
+
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
|
|
770
985
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
771
986
|
if (mounted) {
|
|
772
987
|
if (!mounted.active)
|
|
@@ -801,6 +1016,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
801
1016
|
const view = {
|
|
802
1017
|
sessionId,
|
|
803
1018
|
sessionHandle,
|
|
1019
|
+
clientReadWrite: true,
|
|
1020
|
+
resizeControlRequested: false,
|
|
1021
|
+
visible: true,
|
|
804
1022
|
inputSequence: 0,
|
|
805
1023
|
resizeSequence: 0,
|
|
806
1024
|
controlEpoch: undefined,
|
|
@@ -869,6 +1087,723 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
869
1087
|
.catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
|
|
870
1088
|
return this.#createMountLease(entry);
|
|
871
1089
|
}
|
|
1090
|
+
#mountRouted(sessionId, sessionHandle, viewId, canvas) {
|
|
1091
|
+
const mounted = this.#mountedCanvases.get(canvas);
|
|
1092
|
+
if (mounted) {
|
|
1093
|
+
if (!mounted.active)
|
|
1094
|
+
throw new Error("A released terminal canvas cannot be remounted");
|
|
1095
|
+
if (mounted.sessionHandle !== sessionHandle) {
|
|
1096
|
+
throw new Error("A terminal canvas cannot be reassigned to another session");
|
|
1097
|
+
}
|
|
1098
|
+
mounted.references += 1;
|
|
1099
|
+
if (mounted.disposeTimer !== undefined) {
|
|
1100
|
+
window.clearTimeout(mounted.disposeTimer);
|
|
1101
|
+
mounted.disposeTimer = undefined;
|
|
1102
|
+
}
|
|
1103
|
+
return this.#createMountLease(mounted);
|
|
1104
|
+
}
|
|
1105
|
+
const offscreen = canvas.transferControlToOffscreen();
|
|
1106
|
+
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
1107
|
+
this.#mountGenerationBySurface.set(viewId, generation);
|
|
1108
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
1109
|
+
const entry = {
|
|
1110
|
+
canvas,
|
|
1111
|
+
sessionHandle,
|
|
1112
|
+
sessionId,
|
|
1113
|
+
viewId,
|
|
1114
|
+
generation,
|
|
1115
|
+
references: 1,
|
|
1116
|
+
disposeTimer: undefined,
|
|
1117
|
+
active: true,
|
|
1118
|
+
};
|
|
1119
|
+
this.#mountedCanvases.set(canvas, entry);
|
|
1120
|
+
this.#mountedEntries.add(entry);
|
|
1121
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
1122
|
+
this.#views.set(viewId, {
|
|
1123
|
+
sessionId,
|
|
1124
|
+
sessionHandle,
|
|
1125
|
+
...(session === undefined ? {} : { readWrite: session.readWrite }),
|
|
1126
|
+
clientReadWrite: true,
|
|
1127
|
+
resizeControlRequested: false,
|
|
1128
|
+
visible: true,
|
|
1129
|
+
inputSequence: 0,
|
|
1130
|
+
resizeSequence: 0,
|
|
1131
|
+
controlEpoch: undefined,
|
|
1132
|
+
desiredCols: undefined,
|
|
1133
|
+
desiredRows: undefined,
|
|
1134
|
+
pendingInput: [],
|
|
1135
|
+
lastViewStateSeq: undefined,
|
|
1136
|
+
lastAttachmentEpoch: undefined,
|
|
1137
|
+
claimedEpoch: undefined,
|
|
1138
|
+
claimedRevision: 0,
|
|
1139
|
+
});
|
|
1140
|
+
const activation = this.#routedBySession.get(sessionId);
|
|
1141
|
+
if (activation)
|
|
1142
|
+
activation.viewIds.add(viewId);
|
|
1143
|
+
void this.#ensureRoutedActivation(sessionId, sessionHandle, viewId).catch((error) => {
|
|
1144
|
+
if (!this.#disposed)
|
|
1145
|
+
console.error(`[terminal-runtime] routed activation failed for ${sessionId}`, error);
|
|
1146
|
+
});
|
|
1147
|
+
return this.#createMountLease(entry);
|
|
1148
|
+
}
|
|
1149
|
+
async #ensureRoutedActivation(sessionId, sessionHandle, viewId) {
|
|
1150
|
+
const existing = this.#routedBySession.get(sessionId);
|
|
1151
|
+
if (existing) {
|
|
1152
|
+
existing.viewIds.add(viewId);
|
|
1153
|
+
if (existing.start)
|
|
1154
|
+
await existing.start;
|
|
1155
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1156
|
+
[...existing.viewIds].some((candidate) => {
|
|
1157
|
+
const view = this.#views.get(candidate);
|
|
1158
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1159
|
+
});
|
|
1160
|
+
this.#transitionRouted(existing, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
1161
|
+
this.#declareRoutedDemand(existing);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
const activationId = crypto.randomUUID();
|
|
1165
|
+
const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
|
|
1166
|
+
this.#views.get(viewId)?.clientReadWrite !== false &&
|
|
1167
|
+
this.#views.get(viewId)?.readWrite !== false
|
|
1168
|
+
? "read-write"
|
|
1169
|
+
: "read-only";
|
|
1170
|
+
const entry = {
|
|
1171
|
+
sessionId,
|
|
1172
|
+
sessionHandle,
|
|
1173
|
+
state: initialRoutedActivation(sessionId, activationId, inputPolicy),
|
|
1174
|
+
viewIds: new Set([viewId]),
|
|
1175
|
+
recoveryAttempts: 0,
|
|
1176
|
+
preAuthRemints: { control: 0, frames: 0 },
|
|
1177
|
+
protocolFailures: { control: 0, frames: 0 },
|
|
1178
|
+
};
|
|
1179
|
+
this.#routedBySession.set(sessionId, entry);
|
|
1180
|
+
this.#routedByActivation.set(activationId, entry);
|
|
1181
|
+
entry.start = this.#startRoutedActivation(entry, "mount");
|
|
1182
|
+
try {
|
|
1183
|
+
await entry.start;
|
|
1184
|
+
}
|
|
1185
|
+
finally {
|
|
1186
|
+
delete entry.start;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
#routedTicketMatches(entry, ticket) {
|
|
1190
|
+
return (isRoutedTerminalOpenTicket(ticket) &&
|
|
1191
|
+
ticket.route.cellBootId === ticket.transportGrant.claims.audienceCellBootId &&
|
|
1192
|
+
ticket.route.cellBootId === ticket.attachGrant.claims.audienceCellBootId &&
|
|
1193
|
+
ticket.route.cellBootId === ticket.transportGrant.protected.kid.cellBootId &&
|
|
1194
|
+
ticket.route.cellBootId === ticket.attachGrant.protected.kid.cellBootId &&
|
|
1195
|
+
ticket.transportGrant.claims.clientId === ticket.attachGrant.claims.clientId &&
|
|
1196
|
+
ticket.transportGrant.claims.allowedChannels.includes("control") &&
|
|
1197
|
+
ticket.transportGrant.claims.allowedChannels.includes("frames") &&
|
|
1198
|
+
ticket.attachGrant.claims.sessionId === entry.sessionId &&
|
|
1199
|
+
ticket.attachGrant.claims.routeRevision === ticket.route.routeRevision &&
|
|
1200
|
+
(ticket.route.leaseEpoch === undefined ||
|
|
1201
|
+
ticket.attachGrant.claims.leaseEpoch === undefined ||
|
|
1202
|
+
ticket.route.leaseEpoch === ticket.attachGrant.claims.leaseEpoch));
|
|
1203
|
+
}
|
|
1204
|
+
#routedRenewalMatches(entry, value, previousGeneration) {
|
|
1205
|
+
const ticket = entry.ticket;
|
|
1206
|
+
return (ticket !== undefined &&
|
|
1207
|
+
isRoutedSessionAttachGrant(value) &&
|
|
1208
|
+
value.protected.kid.cellBootId === ticket.route.cellBootId &&
|
|
1209
|
+
value.claims.audienceCellBootId === ticket.route.cellBootId &&
|
|
1210
|
+
value.claims.clientId === ticket.attachGrant.claims.clientId &&
|
|
1211
|
+
value.claims.sessionId === entry.sessionId &&
|
|
1212
|
+
value.claims.routeRevision === ticket.route.routeRevision &&
|
|
1213
|
+
value.claims.leaseEpoch === ticket.attachGrant.claims.leaseEpoch &&
|
|
1214
|
+
value.claims.grantGeneration > previousGeneration);
|
|
1215
|
+
}
|
|
1216
|
+
async #startRoutedActivation(entry, reason) {
|
|
1217
|
+
const host = this.#routedHost;
|
|
1218
|
+
if (!host || this.#disposed || entry.viewIds.size === 0)
|
|
1219
|
+
return;
|
|
1220
|
+
let ticket;
|
|
1221
|
+
try {
|
|
1222
|
+
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
|
|
1229
|
+
return;
|
|
1230
|
+
if (!this.#routedTicketMatches(entry, ticket)) {
|
|
1231
|
+
this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
this.#transitionRouted(entry, { type: "ticket-minted", endpointsPresent: ticket.endpoints !== undefined });
|
|
1235
|
+
if (!ticket.endpoints)
|
|
1236
|
+
return;
|
|
1237
|
+
entry.ticket = ticket;
|
|
1238
|
+
this.#transitionRouted(entry, { type: "transport-ready" });
|
|
1239
|
+
this.#routedControl.attach({
|
|
1240
|
+
cellBootId: ticket.route.cellBootId,
|
|
1241
|
+
controlUrl: ticket.endpoints.controlUrl,
|
|
1242
|
+
transportGrant: ticket.transportGrant,
|
|
1243
|
+
attachGrant: ticket.attachGrant,
|
|
1244
|
+
activationId: entry.state.activationId,
|
|
1245
|
+
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1246
|
+
initialDemand: this.#routedDemand(entry),
|
|
1247
|
+
capabilities: this.#routedCapabilities,
|
|
1248
|
+
});
|
|
1249
|
+
this.#postWorker({
|
|
1250
|
+
type: "routed-frames-attach",
|
|
1251
|
+
request: {
|
|
1252
|
+
cellBootId: ticket.route.cellBootId,
|
|
1253
|
+
sessionHandle: entry.sessionHandle,
|
|
1254
|
+
framesUrl: ticket.endpoints.framesUrl,
|
|
1255
|
+
transportGrant: ticket.transportGrant,
|
|
1256
|
+
attachGrant: ticket.attachGrant,
|
|
1257
|
+
activationId: entry.state.activationId,
|
|
1258
|
+
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1259
|
+
...(this.#routedReceiverCapacities === undefined ? {} : { receiverCapacities: this.#routedReceiverCapacities }),
|
|
1260
|
+
capabilities: this.#routedCapabilities,
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1264
|
+
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1265
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1266
|
+
}
|
|
1267
|
+
#stopRoutedActivation(entry) {
|
|
1268
|
+
this.#routedControl?.detach(entry.state.activationId);
|
|
1269
|
+
this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
|
|
1270
|
+
if (entry.attachTimer !== undefined)
|
|
1271
|
+
window.clearTimeout(entry.attachTimer);
|
|
1272
|
+
delete entry.attachTimer;
|
|
1273
|
+
if (entry.renewalTimer !== undefined)
|
|
1274
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1275
|
+
delete entry.renewalTimer;
|
|
1276
|
+
delete entry.ticket;
|
|
1277
|
+
}
|
|
1278
|
+
#transitionRouted(entry, event) {
|
|
1279
|
+
const previous = entry.state;
|
|
1280
|
+
const next = reduceRoutedActivation(previous, event);
|
|
1281
|
+
if (next === previous)
|
|
1282
|
+
return;
|
|
1283
|
+
entry.state = next;
|
|
1284
|
+
if (next.phase !== "attaching" && entry.attachTimer !== undefined) {
|
|
1285
|
+
window.clearTimeout(entry.attachTimer);
|
|
1286
|
+
delete entry.attachTimer;
|
|
1287
|
+
}
|
|
1288
|
+
if ((next.phase === "unavailable" || next.phase === "ended") && next.phase !== previous.phase) {
|
|
1289
|
+
this.#stopRoutedActivation(entry);
|
|
1290
|
+
}
|
|
1291
|
+
if (!previous.presentationReady && next.presentationReady) {
|
|
1292
|
+
entry.recoveryAttempts = 0;
|
|
1293
|
+
entry.protocolFailures.control = 0;
|
|
1294
|
+
entry.protocolFailures.frames = 0;
|
|
1295
|
+
}
|
|
1296
|
+
this.dispatchEvent(new CustomEvent("routed-activation-state", {
|
|
1297
|
+
detail: { sessionId: entry.sessionId, previous, current: next },
|
|
1298
|
+
}));
|
|
1299
|
+
if (previous.presentationReady !== next.presentationReady || previous.inputAllowed !== next.inputAllowed) {
|
|
1300
|
+
for (const viewId of entry.viewIds) {
|
|
1301
|
+
const view = this.#views.get(viewId);
|
|
1302
|
+
this.dispatchEvent(new CustomEvent("routed-view-readiness", {
|
|
1303
|
+
detail: {
|
|
1304
|
+
sessionId: entry.sessionId,
|
|
1305
|
+
viewId,
|
|
1306
|
+
presentationReady: next.presentationReady,
|
|
1307
|
+
inputAllowed: next.inputAllowed && view?.clientReadWrite === true && view.readWrite !== false,
|
|
1308
|
+
phase: next.phase,
|
|
1309
|
+
},
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
#handleRoutedControlEvent(event) {
|
|
1315
|
+
if (this.#disposed)
|
|
1316
|
+
return;
|
|
1317
|
+
if (event.type === "extension-message") {
|
|
1318
|
+
const host = this.#routedHost;
|
|
1319
|
+
if (!host?.onExtensionMessage)
|
|
1320
|
+
return;
|
|
1321
|
+
try {
|
|
1322
|
+
host.onExtensionMessage(event.message, event.context);
|
|
1323
|
+
}
|
|
1324
|
+
catch (error) {
|
|
1325
|
+
console.error("[terminal-runtime] routed extension handler failed", error);
|
|
1326
|
+
}
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (event.type === "transport-ready") {
|
|
1330
|
+
const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs;
|
|
1331
|
+
this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline);
|
|
1332
|
+
for (const entry of this.#routedBySession.values()) {
|
|
1333
|
+
if (entry.ticket?.route.cellBootId === event.cellBootId && entry.state.phase === "attaching") {
|
|
1334
|
+
this.#armRoutedAttachDeadline(entry, deadline);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (event.type === "control-attached") {
|
|
1340
|
+
const entry = this.#routedByActivation.get(event.attached.activationId);
|
|
1341
|
+
if (!entry || event.attached.sessionId !== entry.sessionId)
|
|
1342
|
+
return;
|
|
1343
|
+
entry.preAuthRemints.control = 0;
|
|
1344
|
+
this.#transitionRouted(entry, {
|
|
1345
|
+
type: "control-attached",
|
|
1346
|
+
grantGeneration: event.attached.grantGenerationAccepted,
|
|
1347
|
+
rights: event.attached.rights,
|
|
1348
|
+
});
|
|
1349
|
+
const previousGeometry = this.#routedGeometry.get(entry.sessionId);
|
|
1350
|
+
if (!event.attached.rights.includes("geometry") &&
|
|
1351
|
+
previousGeometry?.holderViewId !== undefined &&
|
|
1352
|
+
entry.viewIds.has(previousGeometry.holderViewId)) {
|
|
1353
|
+
// The cell auto-releases a holder when renewal drops the geometry
|
|
1354
|
+
// right. Preserve its next CAS revision without retaining authority.
|
|
1355
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1356
|
+
revision: previousGeometry.revision + 1,
|
|
1357
|
+
...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
|
|
1358
|
+
...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
1362
|
+
for (const viewId of entry.viewIds) {
|
|
1363
|
+
const view = this.#views.get(viewId);
|
|
1364
|
+
if (view?.resizeControlRequested &&
|
|
1365
|
+
geometry?.holderViewId !== viewId &&
|
|
1366
|
+
view.desiredCols !== undefined &&
|
|
1367
|
+
view.desiredRows !== undefined) {
|
|
1368
|
+
this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (event.type === "attach-refused") {
|
|
1374
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1375
|
+
if (!entry)
|
|
1376
|
+
return;
|
|
1377
|
+
this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
|
|
1378
|
+
if (entry.state.phase === "recovering") {
|
|
1379
|
+
this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry");
|
|
1380
|
+
}
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (event.type === "cell-status") {
|
|
1384
|
+
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1385
|
+
if (!entry || event.status.sessionId !== entry.sessionId)
|
|
1386
|
+
return;
|
|
1387
|
+
this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
|
|
1388
|
+
const sequence = entry.state.lastCellStatusSequence;
|
|
1389
|
+
window.setTimeout(() => {
|
|
1390
|
+
if (entry.state.lastCellStatusSequence !== sequence)
|
|
1391
|
+
return;
|
|
1392
|
+
this.#transitionRouted(entry, { type: "cell-lease-expired" });
|
|
1393
|
+
}, event.status.leaseTtlMs);
|
|
1394
|
+
if (event.status.presentation.state === "revoked" &&
|
|
1395
|
+
(event.status.presentation.reason === "leg-dead" || event.status.presentation.reason === "stale-route")) {
|
|
1396
|
+
this.#recoverRoutedActivation(entry, event.status.presentation.reason === "stale-route" ? "route-stale" : "retry");
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (event.type === "geometry-committed") {
|
|
1401
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1402
|
+
if (!entry)
|
|
1403
|
+
return;
|
|
1404
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1405
|
+
holderViewId: event.committed.holder.viewId,
|
|
1406
|
+
holderGeneration: event.committed.holder.holderGeneration,
|
|
1407
|
+
revision: event.committed.geometryRevision,
|
|
1408
|
+
cols: event.committed.cols,
|
|
1409
|
+
rows: event.committed.rows,
|
|
1410
|
+
});
|
|
1411
|
+
this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } }));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (event.type === "geometry-refused") {
|
|
1415
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1416
|
+
if (entry && event.refused.geometryRevision !== undefined) {
|
|
1417
|
+
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
1418
|
+
const holder = event.refused.currentHolder;
|
|
1419
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1420
|
+
revision: event.refused.geometryRevision,
|
|
1421
|
+
...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
|
|
1422
|
+
...(previous?.cols === undefined ? {} : { cols: previous.cols }),
|
|
1423
|
+
...(previous?.rows === undefined ? {} : { rows: previous.rows }),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event }));
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (event.type === "transport-closed") {
|
|
1430
|
+
this.#routedAttachDeadlineByCell.delete(event.cellBootId);
|
|
1431
|
+
for (const activationId of event.activationIds) {
|
|
1432
|
+
const entry = this.#routedByActivation.get(activationId);
|
|
1433
|
+
if (!entry)
|
|
1434
|
+
continue;
|
|
1435
|
+
if (event.preAuth || event.refusal) {
|
|
1436
|
+
const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
|
|
1437
|
+
this.#transitionRouted(entry, {
|
|
1438
|
+
type: "transport-failed",
|
|
1439
|
+
...(event.preAuth ? { preAuth: true } : {}),
|
|
1440
|
+
...(event.refusal === undefined ? {} : { retryable: recoverable }),
|
|
1441
|
+
});
|
|
1442
|
+
if (entry.state.phase === "unavailable")
|
|
1443
|
+
continue;
|
|
1444
|
+
this.#recoverRoutedActivation(entry, event.preAuth ? "pre-auth" : "retry", "control");
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
const routeStale = event.code === 4000 || event.code === 4001;
|
|
1448
|
+
if (event.code === 4002) {
|
|
1449
|
+
this.#transitionRouted(entry, { type: "replaced" });
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (event.code === 4003) {
|
|
1453
|
+
if (entry.protocolFailures.control >= 1) {
|
|
1454
|
+
this.#transitionRouted(entry, { type: "leg-lost", channel: "control", resumeCapable: false });
|
|
1455
|
+
this.#transitionRouted(entry, {
|
|
1456
|
+
type: "transport-failed",
|
|
1457
|
+
recoveryExhausted: true,
|
|
1458
|
+
reason: "protocol",
|
|
1459
|
+
});
|
|
1460
|
+
this.dispatchEvent(new CustomEvent("routed-protocol-error", {
|
|
1461
|
+
detail: { sessionId: entry.sessionId, channel: "control", reason: event.reason },
|
|
1462
|
+
}));
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
entry.protocolFailures.control += 1;
|
|
1466
|
+
}
|
|
1467
|
+
this.#transitionRouted(entry, {
|
|
1468
|
+
type: routeStale ? "route-stale" : "leg-lost",
|
|
1469
|
+
channel: "control",
|
|
1470
|
+
resumeCapable: false,
|
|
1471
|
+
});
|
|
1472
|
+
this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "control");
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
#handleRoutedFramesEvent(event) {
|
|
1477
|
+
if (event.type === "frames-attached") {
|
|
1478
|
+
const entry = this.#routedByActivation.get(event.attached.activationId);
|
|
1479
|
+
if (!entry || event.attached.sessionId !== entry.sessionId)
|
|
1480
|
+
return;
|
|
1481
|
+
entry.preAuthRemints.frames = 0;
|
|
1482
|
+
this.#transitionRouted(entry, {
|
|
1483
|
+
type: "frames-attached",
|
|
1484
|
+
outcome: event.attached.outcome,
|
|
1485
|
+
trfIdentity: event.attached.trfIdentity,
|
|
1486
|
+
resumeToken: event.attached.resumeToken,
|
|
1487
|
+
});
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (event.type === "attach-refused") {
|
|
1491
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1492
|
+
if (!entry)
|
|
1493
|
+
return;
|
|
1494
|
+
this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
|
|
1495
|
+
if (entry.state.phase === "recovering") {
|
|
1496
|
+
this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", "frames");
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (event.type === "frames-state") {
|
|
1501
|
+
const entry = this.#routedByActivation.get(event.activationId);
|
|
1502
|
+
if (!entry)
|
|
1503
|
+
return;
|
|
1504
|
+
this.#transitionRouted(entry, {
|
|
1505
|
+
type: "frames-state",
|
|
1506
|
+
state: {
|
|
1507
|
+
activationId: event.activationId,
|
|
1508
|
+
state: event.state,
|
|
1509
|
+
...(event.resumeToken === undefined ? {} : { resumeToken: event.resumeToken }),
|
|
1510
|
+
...(event.appliedContent === undefined ? {} : { appliedContent: event.appliedContent }),
|
|
1511
|
+
},
|
|
1512
|
+
});
|
|
1513
|
+
if (event.state === "active" && event.appliedContent) {
|
|
1514
|
+
this.#transitionRouted(entry, { type: "sync-complete", appliedContent: event.appliedContent });
|
|
1515
|
+
}
|
|
1516
|
+
else if (event.state === "failed") {
|
|
1517
|
+
this.#transitionRouted(entry, { type: "sync-failed" });
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (event.type === "presentation-status") {
|
|
1522
|
+
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1523
|
+
if (!entry)
|
|
1524
|
+
return;
|
|
1525
|
+
this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
|
|
1526
|
+
const sequence = entry.state.lastWorkerStatusSequence;
|
|
1527
|
+
window.setTimeout(() => {
|
|
1528
|
+
if (entry.state.lastWorkerStatusSequence !== sequence)
|
|
1529
|
+
return;
|
|
1530
|
+
this.#transitionRouted(entry, { type: "worker-lease-expired" });
|
|
1531
|
+
}, event.status.leaseTtlMs);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
for (const activationId of event.activationIds) {
|
|
1535
|
+
const entry = this.#routedByActivation.get(activationId);
|
|
1536
|
+
if (!entry)
|
|
1537
|
+
continue;
|
|
1538
|
+
if (event.preAuth || event.refusal) {
|
|
1539
|
+
const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
|
|
1540
|
+
this.#transitionRouted(entry, {
|
|
1541
|
+
type: "transport-failed",
|
|
1542
|
+
...(event.preAuth ? { preAuth: true } : {}),
|
|
1543
|
+
...(event.refusal === undefined ? {} : { retryable: recoverable }),
|
|
1544
|
+
});
|
|
1545
|
+
if (entry.state.phase === "unavailable")
|
|
1546
|
+
continue;
|
|
1547
|
+
this.#resumeRoutedFrames(entry, event.preAuth ? "pre-auth" : "retry");
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
const routeStale = event.code === 4000 || event.code === 4001;
|
|
1551
|
+
if (event.code === 4002) {
|
|
1552
|
+
this.#transitionRouted(entry, { type: "replaced" });
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (event.code === 4003) {
|
|
1556
|
+
if (entry.protocolFailures.frames >= 1) {
|
|
1557
|
+
this.#transitionRouted(entry, { type: "leg-lost", channel: "frames", resumeCapable: false });
|
|
1558
|
+
this.#transitionRouted(entry, {
|
|
1559
|
+
type: "transport-failed",
|
|
1560
|
+
recoveryExhausted: true,
|
|
1561
|
+
reason: "protocol",
|
|
1562
|
+
});
|
|
1563
|
+
this.dispatchEvent(new CustomEvent("routed-protocol-error", {
|
|
1564
|
+
detail: { sessionId: entry.sessionId, channel: "frames", reason: event.reason },
|
|
1565
|
+
}));
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
entry.protocolFailures.frames += 1;
|
|
1569
|
+
}
|
|
1570
|
+
const activationFailed = event.code === 4002 || event.code === 4003;
|
|
1571
|
+
const canResume = !routeStale &&
|
|
1572
|
+
!activationFailed &&
|
|
1573
|
+
this.#routedCapabilities.includes("resume") &&
|
|
1574
|
+
entry.state.resumeToken !== undefined &&
|
|
1575
|
+
entry.state.appliedContent !== undefined &&
|
|
1576
|
+
entry.ticket?.endpoints !== undefined;
|
|
1577
|
+
this.#transitionRouted(entry, {
|
|
1578
|
+
type: routeStale ? "route-stale" : "leg-lost",
|
|
1579
|
+
channel: "frames",
|
|
1580
|
+
resumeCapable: canResume,
|
|
1581
|
+
});
|
|
1582
|
+
if (canResume) {
|
|
1583
|
+
this.#resumeRoutedFrames(entry, "retry");
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "frames");
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
#resumeRoutedFrames(entry, reason) {
|
|
1591
|
+
if (entry.framesResume || this.#disposed)
|
|
1592
|
+
return;
|
|
1593
|
+
const task = (async () => {
|
|
1594
|
+
const host = this.#routedHost;
|
|
1595
|
+
const previousTicket = entry.ticket;
|
|
1596
|
+
const activationId = entry.state.activationId;
|
|
1597
|
+
const resumeToken = entry.state.resumeToken;
|
|
1598
|
+
const appliedContent = entry.state.appliedContent;
|
|
1599
|
+
if (!host || !previousTicket?.endpoints || !resumeToken || !appliedContent) {
|
|
1600
|
+
this.#recoverRoutedActivation(entry, reason, "frames");
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (reason === "pre-auth") {
|
|
1604
|
+
if (entry.preAuthRemints.frames >= 1) {
|
|
1605
|
+
this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
entry.preAuthRemints.frames += 1;
|
|
1609
|
+
}
|
|
1610
|
+
entry.recoveryAttempts += 1;
|
|
1611
|
+
if (entry.recoveryAttempts > 5) {
|
|
1612
|
+
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
let ticket;
|
|
1616
|
+
try {
|
|
1617
|
+
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
this.#recoverRoutedActivation(entry, "retry", "frames");
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (this.#disposed ||
|
|
1624
|
+
this.#routedByActivation.get(activationId) !== entry ||
|
|
1625
|
+
entry.state.activationId !== activationId) {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (!this.#routedTicketMatches(entry, ticket) || !ticket.endpoints) {
|
|
1629
|
+
this.#recoverRoutedActivation(entry, "route-stale", "frames");
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const sameRoute = ticket.route.cellBootId === previousTicket.route.cellBootId &&
|
|
1633
|
+
ticket.route.routeRevision === previousTicket.route.routeRevision &&
|
|
1634
|
+
ticket.route.leaseEpoch === previousTicket.route.leaseEpoch;
|
|
1635
|
+
if (!sameRoute) {
|
|
1636
|
+
this.#transitionRouted(entry, { type: "route-stale" });
|
|
1637
|
+
this.#recoverRoutedActivation(entry, "route-stale", "frames");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
entry.ticket = ticket;
|
|
1641
|
+
this.#routedControl?.renew(activationId, ticket.attachGrant);
|
|
1642
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1643
|
+
this.#postWorker({
|
|
1644
|
+
type: "routed-frames-attach",
|
|
1645
|
+
request: {
|
|
1646
|
+
cellBootId: ticket.route.cellBootId,
|
|
1647
|
+
sessionHandle: entry.sessionHandle,
|
|
1648
|
+
framesUrl: ticket.endpoints.framesUrl,
|
|
1649
|
+
transportGrant: ticket.transportGrant,
|
|
1650
|
+
attachGrant: ticket.attachGrant,
|
|
1651
|
+
activationId,
|
|
1652
|
+
resume: { resumeToken, from: appliedContent },
|
|
1653
|
+
...(this.#routedReceiverCapacities === undefined
|
|
1654
|
+
? {}
|
|
1655
|
+
: { receiverCapacities: this.#routedReceiverCapacities }),
|
|
1656
|
+
capabilities: this.#routedCapabilities,
|
|
1657
|
+
},
|
|
1658
|
+
});
|
|
1659
|
+
})();
|
|
1660
|
+
entry.framesResume = task;
|
|
1661
|
+
void task.finally(() => {
|
|
1662
|
+
if (entry.framesResume === task)
|
|
1663
|
+
delete entry.framesResume;
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
#recoverRoutedActivation(entry, reason, failedChannel) {
|
|
1667
|
+
if (this.#disposed || entry.viewIds.size === 0 || entry.state.phase === "ended")
|
|
1668
|
+
return;
|
|
1669
|
+
if (reason === "pre-auth") {
|
|
1670
|
+
const channel = failedChannel ?? "control";
|
|
1671
|
+
if (entry.preAuthRemints[channel] >= 1) {
|
|
1672
|
+
this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
entry.preAuthRemints[channel] += 1;
|
|
1676
|
+
}
|
|
1677
|
+
entry.recoveryAttempts += 1;
|
|
1678
|
+
if (entry.recoveryAttempts > 5) {
|
|
1679
|
+
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
// Geometry belongs to the cell-side attach/client/view scope. Preserve it
|
|
1683
|
+
// across a same-route leg replacement, but never carry its revision or
|
|
1684
|
+
// holder generation to a newly routed cell.
|
|
1685
|
+
if (reason === "route-stale")
|
|
1686
|
+
this.#routedGeometry.delete(entry.sessionId);
|
|
1687
|
+
const previousActivationId = entry.state.activationId;
|
|
1688
|
+
this.#routedControl?.detach(previousActivationId);
|
|
1689
|
+
this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
|
|
1690
|
+
this.#routedByActivation.delete(previousActivationId);
|
|
1691
|
+
const nextActivationId = crypto.randomUUID();
|
|
1692
|
+
entry.replacesActivationId = previousActivationId;
|
|
1693
|
+
const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
|
|
1694
|
+
[...entry.viewIds].some((viewId) => {
|
|
1695
|
+
const view = this.#views.get(viewId);
|
|
1696
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1697
|
+
})
|
|
1698
|
+
? "read-write"
|
|
1699
|
+
: "read-only";
|
|
1700
|
+
entry.state = {
|
|
1701
|
+
...initialRoutedActivation(entry.sessionId, nextActivationId, inputPolicy),
|
|
1702
|
+
phase: "recovering",
|
|
1703
|
+
replacesActivationId: previousActivationId,
|
|
1704
|
+
preAuthRemintUsed: entry.preAuthRemints.control > 0 || entry.preAuthRemints.frames > 0,
|
|
1705
|
+
};
|
|
1706
|
+
this.#routedByActivation.set(nextActivationId, entry);
|
|
1707
|
+
if (entry.attachTimer !== undefined)
|
|
1708
|
+
window.clearTimeout(entry.attachTimer);
|
|
1709
|
+
delete entry.attachTimer;
|
|
1710
|
+
if (entry.renewalTimer !== undefined)
|
|
1711
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1712
|
+
delete entry.renewalTimer;
|
|
1713
|
+
delete entry.ticket;
|
|
1714
|
+
const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
|
|
1715
|
+
window.setTimeout(() => {
|
|
1716
|
+
if (this.#routedByActivation.get(nextActivationId) !== entry)
|
|
1717
|
+
return;
|
|
1718
|
+
entry.start = this.#startRoutedActivation(entry, reason);
|
|
1719
|
+
void entry.start.finally(() => delete entry.start);
|
|
1720
|
+
}, delay);
|
|
1721
|
+
}
|
|
1722
|
+
#armRoutedAttachDeadline(entry, delayMs) {
|
|
1723
|
+
if (entry.attachTimer !== undefined)
|
|
1724
|
+
window.clearTimeout(entry.attachTimer);
|
|
1725
|
+
const activationId = entry.state.activationId;
|
|
1726
|
+
entry.attachTimer = window.setTimeout(() => {
|
|
1727
|
+
if (this.#routedByActivation.get(activationId) !== entry || entry.state.phase !== "attaching")
|
|
1728
|
+
return;
|
|
1729
|
+
this.#transitionRouted(entry, { type: "attach-deadline" });
|
|
1730
|
+
this.#recoverRoutedActivation(entry, "retry");
|
|
1731
|
+
}, Math.max(0, delayMs));
|
|
1732
|
+
}
|
|
1733
|
+
#scheduleRoutedRenewal(entry) {
|
|
1734
|
+
const ticket = entry.ticket;
|
|
1735
|
+
const host = this.#routedHost;
|
|
1736
|
+
if (!ticket || !host)
|
|
1737
|
+
return;
|
|
1738
|
+
if (entry.renewalTimer !== undefined)
|
|
1739
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1740
|
+
const generation = ticket.attachGrant.claims.grantGeneration;
|
|
1741
|
+
const delay = Math.max(0, ticket.attachGrant.claims.expiresAt - Date.now() - 60_000);
|
|
1742
|
+
if (delay > MAX_BROWSER_TIMEOUT_MS) {
|
|
1743
|
+
entry.renewalTimer = window.setTimeout(() => {
|
|
1744
|
+
if (entry.ticket !== ticket || this.#disposed)
|
|
1745
|
+
return;
|
|
1746
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1747
|
+
}, MAX_BROWSER_TIMEOUT_MS);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
entry.renewalTimer = window.setTimeout(() => {
|
|
1751
|
+
this.#transitionRouted(entry, { type: "grant-expiring" });
|
|
1752
|
+
if (!host.renewAttach) {
|
|
1753
|
+
this.#transitionRouted(entry, { type: "renew-failed" });
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const requestId = crypto.randomUUID();
|
|
1757
|
+
void host
|
|
1758
|
+
.renewAttach({ sessionId: entry.sessionId, expectGeneration: generation, requestId })
|
|
1759
|
+
.then(({ attachGrant }) => {
|
|
1760
|
+
if (entry.ticket !== ticket || !this.#routedRenewalMatches(entry, attachGrant, generation)) {
|
|
1761
|
+
this.#transitionRouted(entry, { type: "renew-failed" });
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
entry.ticket = { ...ticket, attachGrant };
|
|
1765
|
+
this.#routedControl?.renew(entry.state.activationId, attachGrant);
|
|
1766
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1767
|
+
})
|
|
1768
|
+
.catch(() => this.#transitionRouted(entry, { type: "renew-failed" }));
|
|
1769
|
+
}, delay);
|
|
1770
|
+
}
|
|
1771
|
+
#routedDemand(entry) {
|
|
1772
|
+
let live = false;
|
|
1773
|
+
let urgent = false;
|
|
1774
|
+
for (const viewId of entry.viewIds) {
|
|
1775
|
+
const view = this.#views.get(viewId);
|
|
1776
|
+
live ||= view?.visible === true;
|
|
1777
|
+
urgent ||= view?.visible === true && this.#focusByView.get(viewId) === true;
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
mode: live ? "live" : "none",
|
|
1781
|
+
urgency: urgent ? "urgent" : "normal",
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
#declareRoutedDemand(entry) {
|
|
1785
|
+
this.#routedControl?.declareDemand(entry.state.activationId, this.#routedDemand(entry));
|
|
1786
|
+
}
|
|
1787
|
+
#releaseRoutedView(sessionId, viewId) {
|
|
1788
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
1789
|
+
if (!entry)
|
|
1790
|
+
return;
|
|
1791
|
+
entry.viewIds.delete(viewId);
|
|
1792
|
+
if (entry.viewIds.size > 0) {
|
|
1793
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1794
|
+
[...entry.viewIds].some((candidate) => {
|
|
1795
|
+
const view = this.#views.get(candidate);
|
|
1796
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1797
|
+
});
|
|
1798
|
+
this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
1799
|
+
this.#declareRoutedDemand(entry);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
this.#transitionRouted(entry, { type: "detach" });
|
|
1803
|
+
this.#routedBySession.delete(sessionId);
|
|
1804
|
+
this.#routedByActivation.delete(entry.state.activationId);
|
|
1805
|
+
this.#routedGeometry.delete(sessionId);
|
|
1806
|
+
}
|
|
872
1807
|
#createMountLease(mounted) {
|
|
873
1808
|
let disposed = false;
|
|
874
1809
|
return {
|
|
@@ -891,13 +1826,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
891
1826
|
if (ownsWorkerSurface) {
|
|
892
1827
|
this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
|
|
893
1828
|
this.#mountGenerationBySurface.delete(mounted.viewId);
|
|
894
|
-
this.#
|
|
1829
|
+
if (this.#routedHost)
|
|
1830
|
+
this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
|
|
1831
|
+
else
|
|
1832
|
+
this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
|
|
895
1833
|
this.#views.delete(mounted.viewId);
|
|
896
1834
|
this.#focusByView.delete(mounted.viewId);
|
|
897
1835
|
}
|
|
898
1836
|
this.#mountedCanvases.delete(mounted.canvas);
|
|
899
1837
|
this.#mountedEntries.delete(mounted);
|
|
900
|
-
this.#
|
|
1838
|
+
if (!this.#routedHost)
|
|
1839
|
+
this.#releaseFrameSubscription(mounted.sessionHandle);
|
|
901
1840
|
}, 0);
|
|
902
1841
|
},
|
|
903
1842
|
};
|
|
@@ -950,8 +1889,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
950
1889
|
this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
|
|
951
1890
|
}
|
|
952
1891
|
}
|
|
953
|
-
// A cleared controller is the one case worth re-evaluating:
|
|
954
|
-
//
|
|
1892
|
+
// A cleared controller is the one case worth re-evaluating: a view with an
|
|
1893
|
+
// outstanding explicit resize-control request may now take the seat.
|
|
955
1894
|
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
956
1895
|
this.#maybeReclaim(viewId);
|
|
957
1896
|
}
|
|
@@ -963,10 +1902,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
963
1902
|
}
|
|
964
1903
|
/**
|
|
965
1904
|
* The single funnel for taking resize control (§4.2.3). Every condition that
|
|
966
|
-
* gates
|
|
967
|
-
* enough: recovery
|
|
968
|
-
*
|
|
969
|
-
* either alone would be skipped and never retried.
|
|
1905
|
+
* gates an explicit claim re-enters here when it changes, because no one
|
|
1906
|
+
* event is enough: recovery can mark a view attached before its session
|
|
1907
|
+
* reaches live, while the resize-control request already exists.
|
|
970
1908
|
*
|
|
971
1909
|
* At most one claim per attachment epoch, plus one more each time the
|
|
972
1910
|
* controller is cleared at a newer revision.
|
|
@@ -980,15 +1918,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
980
1918
|
*/
|
|
981
1919
|
#maybeReclaim(viewId) {
|
|
982
1920
|
const view = this.#views.get(viewId);
|
|
983
|
-
if (!view || view.readWrite === false)
|
|
1921
|
+
if (!view || view.readWrite === false || !view.clientReadWrite || !view.resizeControlRequested)
|
|
984
1922
|
return;
|
|
985
1923
|
const attachmentEpoch = view.attachmentEpoch;
|
|
986
1924
|
if (attachmentEpoch === undefined)
|
|
987
1925
|
return;
|
|
988
1926
|
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
989
1927
|
return;
|
|
990
|
-
if (this.#focusByView.get(viewId) !== true)
|
|
991
|
-
return;
|
|
992
1928
|
const remote = this.#remoteSessions.get(view.sessionId);
|
|
993
1929
|
if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
|
|
994
1930
|
return;
|
|
@@ -1181,7 +2117,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1181
2117
|
*/
|
|
1182
2118
|
#sendViewInput(viewId, operation, silent = false) {
|
|
1183
2119
|
const view = this.#views.get(viewId);
|
|
1184
|
-
if (!view || view.readWrite === false)
|
|
2120
|
+
if (!view || view.readWrite === false || !view.clientReadWrite)
|
|
1185
2121
|
return;
|
|
1186
2122
|
const remote = this.#remoteSessions.get(view.sessionId);
|
|
1187
2123
|
const attachmentEpoch = view.attachmentEpoch;
|
|
@@ -1206,36 +2142,100 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1206
2142
|
#reportSuppressedInput(sessionId, viewId, state) {
|
|
1207
2143
|
this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
|
|
1208
2144
|
}
|
|
2145
|
+
#sendRoutedInput(sessionId, viewId, operation, silent = false) {
|
|
2146
|
+
const host = this.#routedHost;
|
|
2147
|
+
const view = this.#views.get(viewId);
|
|
2148
|
+
const activation = this.#routedBySession.get(sessionId);
|
|
2149
|
+
if (!host ||
|
|
2150
|
+
!view ||
|
|
2151
|
+
view.sessionId !== sessionId ||
|
|
2152
|
+
view.readWrite === false ||
|
|
2153
|
+
!view.clientReadWrite ||
|
|
2154
|
+
!activation?.state.inputAllowed) {
|
|
2155
|
+
if (!silent) {
|
|
2156
|
+
this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
|
|
2157
|
+
detail: {
|
|
2158
|
+
sessionId,
|
|
2159
|
+
viewId,
|
|
2160
|
+
reason: !host?.encodeInput ? "wire-verb-unavailable" : "input-not-allowed",
|
|
2161
|
+
},
|
|
2162
|
+
}));
|
|
2163
|
+
}
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
if (!host.encodeInput) {
|
|
2167
|
+
if (!silent) {
|
|
2168
|
+
this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
|
|
2169
|
+
detail: { sessionId, viewId, reason: "wire-verb-unavailable" },
|
|
2170
|
+
}));
|
|
2171
|
+
}
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
view.inputSequence += 1;
|
|
2175
|
+
const message = host.encodeInput({
|
|
2176
|
+
sessionId,
|
|
2177
|
+
viewId,
|
|
2178
|
+
activationId: activation.state.activationId,
|
|
2179
|
+
leaseEpoch: activation.ticket?.attachGrant.claims.leaseEpoch ?? 0,
|
|
2180
|
+
inputSequence: view.inputSequence,
|
|
2181
|
+
operation,
|
|
2182
|
+
});
|
|
2183
|
+
return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
|
|
2184
|
+
}
|
|
1209
2185
|
sendText(sessionId, viewId, text) {
|
|
2186
|
+
if (this.#routedHost) {
|
|
2187
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
1210
2190
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
1211
2191
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1212
2192
|
if (handle)
|
|
1213
2193
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1214
2194
|
}
|
|
1215
2195
|
paste(sessionId, viewId, text) {
|
|
2196
|
+
if (this.#routedHost) {
|
|
2197
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
1216
2200
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "paste", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
1217
2201
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1218
2202
|
if (handle)
|
|
1219
2203
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1220
2204
|
}
|
|
1221
2205
|
sendKey(sessionId, viewId, event) {
|
|
2206
|
+
if (this.#routedHost) {
|
|
2207
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
1222
2210
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-key", sessionId, viewId, attachmentEpoch, inputSequence, event }));
|
|
1223
2211
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1224
2212
|
if (handle)
|
|
1225
2213
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1226
2214
|
}
|
|
1227
2215
|
sendMouse(sessionId, viewId, event) {
|
|
2216
|
+
if (this.#routedHost) {
|
|
2217
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "mouse", event }, true);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
1228
2220
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
|
|
1229
2221
|
}
|
|
1230
2222
|
scroll(sessionId, viewId, rows) {
|
|
1231
2223
|
if (rows === 0)
|
|
1232
2224
|
return;
|
|
2225
|
+
if (this.#routedHost) {
|
|
2226
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "scroll", rows }, true);
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
1233
2229
|
// Scrolling is host-side input, so it is simply inert while frozen.
|
|
1234
2230
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
|
|
1235
2231
|
}
|
|
1236
2232
|
scrollTo(sessionId, viewId, row) {
|
|
1237
2233
|
if (!Number.isSafeInteger(row) || row < 0)
|
|
1238
2234
|
return;
|
|
2235
|
+
if (this.#routedHost) {
|
|
2236
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "scroll-to", row }, true);
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
1239
2239
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
|
|
1240
2240
|
}
|
|
1241
2241
|
scrollbar(sessionHandle) {
|
|
@@ -1246,6 +2246,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1246
2246
|
}
|
|
1247
2247
|
setTheme(sessionHandle, theme, surfaceId) {
|
|
1248
2248
|
this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
|
|
2249
|
+
// A surface-scoped theme is renderer-local. Updating the daemon's
|
|
2250
|
+
// session-wide palette here would repaint every mirrored viewer.
|
|
2251
|
+
if (surfaceId)
|
|
2252
|
+
return;
|
|
1249
2253
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
1250
2254
|
if (!session)
|
|
1251
2255
|
return;
|
|
@@ -1270,6 +2274,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1270
2274
|
}
|
|
1271
2275
|
setVisible(sessionHandle, visible, surfaceId) {
|
|
1272
2276
|
this.#postWorker({ type: "visibility", sessionHandle, ...(surfaceId ? { surfaceId } : {}), visible });
|
|
2277
|
+
if (this.#routedHost) {
|
|
2278
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2279
|
+
if (!session)
|
|
2280
|
+
return;
|
|
2281
|
+
if (surfaceId) {
|
|
2282
|
+
const view = this.#views.get(surfaceId);
|
|
2283
|
+
if (view)
|
|
2284
|
+
view.visible = visible;
|
|
2285
|
+
}
|
|
2286
|
+
else {
|
|
2287
|
+
for (const view of this.#views.values()) {
|
|
2288
|
+
if (view.sessionId === session.id)
|
|
2289
|
+
view.visible = visible;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const entry = this.#routedBySession.get(session.id);
|
|
2293
|
+
if (entry)
|
|
2294
|
+
this.#declareRoutedDemand(entry);
|
|
2295
|
+
}
|
|
1273
2296
|
}
|
|
1274
2297
|
forceFullRedraw(sessionHandle) {
|
|
1275
2298
|
this.#postWorker({ type: "force-full-redraw", sessionHandle });
|
|
@@ -1280,28 +2303,92 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1280
2303
|
setPartialRenderingEnabled(enabled) {
|
|
1281
2304
|
this.#postWorker({ type: "partial-rendering", enabled });
|
|
1282
2305
|
}
|
|
2306
|
+
#claimRoutedGeometry(entry, viewId, cols, rows) {
|
|
2307
|
+
const ticket = entry.ticket;
|
|
2308
|
+
const view = this.#views.get(viewId);
|
|
2309
|
+
if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
|
|
2310
|
+
return;
|
|
2311
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2312
|
+
this.#routedControl?.claimGeometry(entry.state.activationId, {
|
|
2313
|
+
sessionId: entry.sessionId,
|
|
2314
|
+
activationId: entry.state.activationId,
|
|
2315
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2316
|
+
claimant: { clientId: ticket.attachGrant.claims.clientId, viewId },
|
|
2317
|
+
cols,
|
|
2318
|
+
rows,
|
|
2319
|
+
expectRevision: geometry?.revision ?? 0,
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
1283
2322
|
claimResizeControl(sessionHandle, viewId, cols, rows) {
|
|
1284
2323
|
const view = this.#views.get(viewId);
|
|
1285
2324
|
if (view) {
|
|
1286
2325
|
view.desiredCols = cols;
|
|
1287
2326
|
view.desiredRows = rows;
|
|
1288
|
-
|
|
1289
|
-
view.claimedEpoch = view.attachmentEpoch;
|
|
1290
|
-
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
2327
|
+
view.resizeControlRequested = true;
|
|
1291
2328
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
2329
|
+
if (this.#routedHost) {
|
|
2330
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2331
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
2332
|
+
if (entry)
|
|
2333
|
+
this.#claimRoutedGeometry(entry, viewId, cols, rows);
|
|
1294
2334
|
return;
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2335
|
+
}
|
|
2336
|
+
if (!this.#sessionByHandle.has(sessionHandle))
|
|
2337
|
+
return;
|
|
2338
|
+
this.#maybeReclaim(viewId);
|
|
2339
|
+
}
|
|
2340
|
+
releaseResizeControl(viewId) {
|
|
2341
|
+
const view = this.#views.get(viewId);
|
|
2342
|
+
if (!view)
|
|
2343
|
+
return;
|
|
2344
|
+
view.resizeControlRequested = false;
|
|
2345
|
+
if (this.#routedHost) {
|
|
2346
|
+
const entry = this.#routedBySession.get(view.sessionId);
|
|
2347
|
+
const geometry = this.#routedGeometry.get(view.sessionId);
|
|
2348
|
+
const ticket = entry?.ticket;
|
|
2349
|
+
if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) {
|
|
2350
|
+
const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
|
|
2351
|
+
sessionId: view.sessionId,
|
|
2352
|
+
activationId: entry.state.activationId,
|
|
2353
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2354
|
+
holder: {
|
|
2355
|
+
clientId: ticket.attachGrant.claims.clientId,
|
|
2356
|
+
viewId,
|
|
2357
|
+
holderGeneration: geometry.holderGeneration,
|
|
2358
|
+
},
|
|
2359
|
+
});
|
|
2360
|
+
if (sent) {
|
|
2361
|
+
// T1 sends no success body for release. The ordered control leg and
|
|
2362
|
+
// cell state machine make the next revision deterministic.
|
|
2363
|
+
this.#routedGeometry.set(view.sessionId, {
|
|
2364
|
+
revision: geometry.revision + 1,
|
|
2365
|
+
...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
|
|
2366
|
+
...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
// The legacy protocol has no release verb. Clearing the local epoch still
|
|
2373
|
+
// closes every resize path immediately; a later explicit claim can renew it.
|
|
2374
|
+
view.controlEpoch = undefined;
|
|
2375
|
+
}
|
|
2376
|
+
setViewInputPolicy(viewId, readWrite) {
|
|
2377
|
+
const view = this.#views.get(viewId);
|
|
2378
|
+
if (!view)
|
|
2379
|
+
return;
|
|
2380
|
+
view.clientReadWrite = readWrite;
|
|
2381
|
+
if (!readWrite)
|
|
2382
|
+
view.pendingInput.length = 0;
|
|
2383
|
+
const entry = this.#routedBySession.get(view.sessionId);
|
|
2384
|
+
if (entry) {
|
|
2385
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
2386
|
+
[...entry.viewIds].some((candidate) => {
|
|
2387
|
+
const candidateView = this.#views.get(candidate);
|
|
2388
|
+
return candidateView?.clientReadWrite === true && candidateView.readWrite !== false;
|
|
2389
|
+
});
|
|
2390
|
+
this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
2391
|
+
}
|
|
1305
2392
|
}
|
|
1306
2393
|
setFocused(sessionHandle, viewId, focused, cols, rows) {
|
|
1307
2394
|
const view = this.#views.get(viewId);
|
|
@@ -1310,10 +2397,6 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1310
2397
|
view.desiredRows = rows;
|
|
1311
2398
|
}
|
|
1312
2399
|
if (this.#focusByView.get(viewId) === focused) {
|
|
1313
|
-
// Focus has not moved, so the claim below will not run — but an epoch or
|
|
1314
|
-
// controller change since the last update may have made one possible,
|
|
1315
|
-
// and nothing else would ever retry it after a resume.
|
|
1316
|
-
this.#maybeReclaim(viewId);
|
|
1317
2400
|
return;
|
|
1318
2401
|
}
|
|
1319
2402
|
this.#focusByView.set(viewId, focused);
|
|
@@ -1321,6 +2404,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1321
2404
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
1322
2405
|
if (!session)
|
|
1323
2406
|
return;
|
|
2407
|
+
if (this.#routedHost) {
|
|
2408
|
+
const entry = this.#routedBySession.get(session.id);
|
|
2409
|
+
if (entry)
|
|
2410
|
+
this.#declareRoutedDemand(entry);
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
1324
2413
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
|
|
1325
2414
|
this.#control?.notify({
|
|
1326
2415
|
type: "focus",
|
|
@@ -1330,25 +2419,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1330
2419
|
inputSequence,
|
|
1331
2420
|
focused,
|
|
1332
2421
|
});
|
|
1333
|
-
if (focused) {
|
|
1334
|
-
// Taking focus is a deliberate claim, and counts as this epoch's.
|
|
1335
|
-
if (view) {
|
|
1336
|
-
view.claimedEpoch = attachmentEpoch;
|
|
1337
|
-
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
1338
|
-
}
|
|
1339
|
-
this.#control?.notify({
|
|
1340
|
-
type: "focus-and-resize",
|
|
1341
|
-
sessionId: session.id,
|
|
1342
|
-
viewId,
|
|
1343
|
-
attachmentEpoch,
|
|
1344
|
-
cols,
|
|
1345
|
-
rows,
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
2422
|
}, true);
|
|
1349
2423
|
}
|
|
1350
2424
|
async copySelection(sessionId, viewId, selection, selectAll = false) {
|
|
1351
2425
|
await this.connect();
|
|
2426
|
+
if (this.#routedHost)
|
|
2427
|
+
return "";
|
|
1352
2428
|
const view = this.#views.get(viewId);
|
|
1353
2429
|
if (!view || view.sessionId !== sessionId)
|
|
1354
2430
|
return "";
|
|
@@ -1383,6 +2459,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1383
2459
|
return response.text;
|
|
1384
2460
|
}
|
|
1385
2461
|
interrupt(sessionId, viewId) {
|
|
2462
|
+
if (this.#routedHost) {
|
|
2463
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
1386
2466
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "interrupt", sessionId, viewId, attachmentEpoch, inputSequence }));
|
|
1387
2467
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1388
2468
|
if (handle)
|
|
@@ -1431,6 +2511,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1431
2511
|
}
|
|
1432
2512
|
}
|
|
1433
2513
|
this.#sessionByHandle.delete(handle);
|
|
2514
|
+
this.#sessionGenerationByHandle.delete(handle);
|
|
2515
|
+
this.#appliedRoutedExitEvents.delete(handle);
|
|
1434
2516
|
this.#handleBySessionId.delete(sessionId);
|
|
1435
2517
|
this.#remoteSessions.delete(sessionId);
|
|
1436
2518
|
this.#controlBySession.delete(sessionId);
|
|
@@ -1443,9 +2525,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1443
2525
|
this.#postWorker({ type: "drop-session", sessionHandle: handle });
|
|
1444
2526
|
}
|
|
1445
2527
|
unregisterSession(sessionId) {
|
|
2528
|
+
const routed = this.#routedBySession.get(sessionId);
|
|
2529
|
+
if (routed) {
|
|
2530
|
+
for (const viewId of [...routed.viewIds])
|
|
2531
|
+
this.#releaseRoutedView(sessionId, viewId);
|
|
2532
|
+
}
|
|
1446
2533
|
this.#removeRegisteredSession(sessionId, true);
|
|
1447
2534
|
}
|
|
1448
2535
|
terminate(sessionId, source = "user") {
|
|
2536
|
+
if (this.#routedHost) {
|
|
2537
|
+
void this.#routedHost.terminate?.(sessionId, source);
|
|
2538
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
2539
|
+
if (entry) {
|
|
2540
|
+
for (const viewId of [...entry.viewIds])
|
|
2541
|
+
this.#releaseRoutedView(sessionId, viewId);
|
|
2542
|
+
}
|
|
2543
|
+
this.#removeRegisteredSession(sessionId, false);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
1449
2546
|
this.#control?.notify({ type: "terminate", sessionId, source });
|
|
1450
2547
|
this.#removeRegisteredSession(sessionId, false);
|
|
1451
2548
|
}
|
|
@@ -1455,6 +2552,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1455
2552
|
return;
|
|
1456
2553
|
view.desiredCols = cols;
|
|
1457
2554
|
view.desiredRows = rows;
|
|
2555
|
+
if (!view.resizeControlRequested)
|
|
2556
|
+
return;
|
|
2557
|
+
if (this.#routedHost) {
|
|
2558
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
2559
|
+
if (entry)
|
|
2560
|
+
this.#claimRoutedGeometry(entry, viewId, cols, rows);
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
1458
2563
|
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
|
|
1459
2564
|
// Dimensions are one of the funnel's conditions: a pane that measured
|
|
1460
2565
|
// itself while uncontrolled may now be able to take control.
|
|
@@ -1489,12 +2594,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1489
2594
|
for (const timer of this.#metadataTimers.values())
|
|
1490
2595
|
window.clearTimeout(timer);
|
|
1491
2596
|
this.#metadataTimers.clear();
|
|
2597
|
+
this.#metadataRefreshPending.clear();
|
|
2598
|
+
this.#metadataRefreshes.clear();
|
|
2599
|
+
this.#sessionGenerationByHandle.clear();
|
|
2600
|
+
this.#appliedRoutedExitEvents.clear();
|
|
1492
2601
|
this.#resync.dispose();
|
|
1493
2602
|
for (const request of this.#performanceRequests.values()) {
|
|
1494
2603
|
window.clearTimeout(request.timer);
|
|
1495
2604
|
request.reject(new Error("Terminal runtime was disposed during a performance request"));
|
|
1496
2605
|
}
|
|
1497
2606
|
this.#performanceRequests.clear();
|
|
2607
|
+
for (const request of this.#counterRequests.values()) {
|
|
2608
|
+
window.clearTimeout(request.timer);
|
|
2609
|
+
request.reject(new Error("Terminal runtime was disposed during a counter request"));
|
|
2610
|
+
}
|
|
2611
|
+
this.#counterRequests.clear();
|
|
1498
2612
|
this.#views.clear();
|
|
1499
2613
|
this.#remoteSessions.clear();
|
|
1500
2614
|
this.#controlBySession.clear();
|
|
@@ -1515,15 +2629,28 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1515
2629
|
}
|
|
1516
2630
|
this.#control?.dispose();
|
|
1517
2631
|
this.#control = undefined;
|
|
2632
|
+
this.#routedControl?.dispose();
|
|
2633
|
+
for (const entry of this.#routedBySession.values()) {
|
|
2634
|
+
if (entry.attachTimer !== undefined)
|
|
2635
|
+
window.clearTimeout(entry.attachTimer);
|
|
2636
|
+
if (entry.renewalTimer !== undefined)
|
|
2637
|
+
window.clearTimeout(entry.renewalTimer);
|
|
2638
|
+
}
|
|
2639
|
+
this.#routedBySession.clear();
|
|
2640
|
+
this.#routedByActivation.clear();
|
|
2641
|
+
this.#routedGeometry.clear();
|
|
2642
|
+
this.#routedAttachDeadlineByCell.clear();
|
|
1518
2643
|
this.#serverProtocolMinor = 0;
|
|
1519
2644
|
this.#worker.terminate();
|
|
1520
|
-
|
|
1521
|
-
ports.
|
|
1522
|
-
|
|
1523
|
-
|
|
2645
|
+
if (this.#ports) {
|
|
2646
|
+
void this.#ports.then((ports) => {
|
|
2647
|
+
ports.control.close();
|
|
2648
|
+
ports.frames.close();
|
|
2649
|
+
}, () => undefined);
|
|
2650
|
+
}
|
|
1524
2651
|
}
|
|
1525
2652
|
#sendResize(viewId, view, cols, rows) {
|
|
1526
|
-
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
2653
|
+
if (!view.resizeControlRequested || view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
1527
2654
|
return;
|
|
1528
2655
|
view.resizeSequence += 1;
|
|
1529
2656
|
this.#control?.notify({
|