@vibecook/ghosttea-react 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -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 +69 -0
- package/dist/routed-control.d.ts.map +1 -0
- package/dist/routed-control.js +383 -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 +73 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1034 -53
- 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;
|
|
@@ -90,13 +104,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
90
104
|
#resync;
|
|
91
105
|
#performanceRequestId = 1;
|
|
92
106
|
#performanceRequests = new Map();
|
|
107
|
+
#counterRequests = new Map();
|
|
93
108
|
#disposed = false;
|
|
94
109
|
constructor(options) {
|
|
95
110
|
super();
|
|
96
111
|
this.#worker =
|
|
97
112
|
options.workerFactory?.() ??
|
|
98
113
|
new Worker(new URL("./terminal-render.worker.js", import.meta.url), { type: "module" });
|
|
99
|
-
this.#ports = Promise.resolve(options.ports);
|
|
114
|
+
this.#ports = options.transport === "routed" ? undefined : Promise.resolve(options.ports);
|
|
115
|
+
this.#routedHost = options.transport === "routed" ? options.host : undefined;
|
|
116
|
+
this.#routedReceiverCapacities = options.transport === "routed" ? options.receiverCapacities : undefined;
|
|
117
|
+
this.#routedCapabilities = options.transport === "routed" ? (options.capabilities ?? ["resume"]) : [];
|
|
118
|
+
this.#routedControl =
|
|
119
|
+
options.transport === "routed"
|
|
120
|
+
? new RoutedControlTransport({
|
|
121
|
+
...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }),
|
|
122
|
+
emit: (event) => this.#handleRoutedControlEvent(event),
|
|
123
|
+
})
|
|
124
|
+
: undefined;
|
|
100
125
|
this.#platform = options.platform;
|
|
101
126
|
this.#clientBuild = options.clientBuild ?? "ghosttea-react";
|
|
102
127
|
this.#sessionOwnerId = options.sessionOwnerId;
|
|
@@ -152,6 +177,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
152
177
|
else if (data.type === "performance-result") {
|
|
153
178
|
this.#resolvePerformanceRequest(data.requestId, data.snapshot);
|
|
154
179
|
}
|
|
180
|
+
else if (data.type === "performance-counters") {
|
|
181
|
+
const pending = this.#counterRequests.get(data.requestId);
|
|
182
|
+
if (!pending)
|
|
183
|
+
return;
|
|
184
|
+
window.clearTimeout(pending.timer);
|
|
185
|
+
this.#counterRequests.delete(data.requestId);
|
|
186
|
+
pending.resolve(data.snapshot);
|
|
187
|
+
}
|
|
188
|
+
else if (data.type === "routed-frames-event") {
|
|
189
|
+
this.#handleRoutedFramesEvent(data.event);
|
|
190
|
+
}
|
|
155
191
|
else if (data.type === "renderer-reload-required") {
|
|
156
192
|
console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
|
|
157
193
|
this.#platform.setForceCanvasFallback(true);
|
|
@@ -171,6 +207,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
171
207
|
get rendererBackend() {
|
|
172
208
|
return this.#rendererBackend;
|
|
173
209
|
}
|
|
210
|
+
/** Current main-authority state for a routed session. */
|
|
211
|
+
routedActivation(sessionId) {
|
|
212
|
+
return this.#routedBySession.get(sessionId)?.state;
|
|
213
|
+
}
|
|
214
|
+
routedViewInputAllowed(viewId) {
|
|
215
|
+
const view = this.#views.get(viewId);
|
|
216
|
+
if (!view?.clientReadWrite || view.readWrite === false)
|
|
217
|
+
return false;
|
|
218
|
+
return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
|
|
219
|
+
}
|
|
174
220
|
#resolvePerformanceRequest(requestId, value) {
|
|
175
221
|
const pending = this.#performanceRequests.get(requestId);
|
|
176
222
|
if (!pending)
|
|
@@ -203,6 +249,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
203
249
|
throw new Error("Terminal render worker returned no performance snapshot");
|
|
204
250
|
return result;
|
|
205
251
|
}
|
|
252
|
+
/** Reads monotonic production counters without starting a sample window or draining the GPU. */
|
|
253
|
+
readPerformanceCounters(timeoutMs = 2_000) {
|
|
254
|
+
if (this.#disposed)
|
|
255
|
+
return Promise.reject(new Error("Terminal runtime is disposed"));
|
|
256
|
+
const requestId = this.#performanceRequestId++;
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const timer = window.setTimeout(() => {
|
|
259
|
+
this.#counterRequests.delete(requestId);
|
|
260
|
+
reject(new Error(`Terminal render counter request ${requestId} timed out`));
|
|
261
|
+
}, timeoutMs);
|
|
262
|
+
this.#counterRequests.set(requestId, { resolve, reject, timer });
|
|
263
|
+
this.#postWorker({ type: "performance-counters", requestId });
|
|
264
|
+
});
|
|
265
|
+
}
|
|
206
266
|
connect() {
|
|
207
267
|
if (this.#disposed)
|
|
208
268
|
return Promise.reject(new Error("Terminal runtime is disposed"));
|
|
@@ -210,6 +270,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
210
270
|
return this.#ready;
|
|
211
271
|
}
|
|
212
272
|
async #connect() {
|
|
273
|
+
if (this.#routedHost)
|
|
274
|
+
return;
|
|
213
275
|
const ports = await this.#ports;
|
|
214
276
|
if (this.#disposed) {
|
|
215
277
|
ports.control.close();
|
|
@@ -342,6 +404,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
342
404
|
}
|
|
343
405
|
async reloadConfig() {
|
|
344
406
|
await this.connect();
|
|
407
|
+
if (this.#routedHost)
|
|
408
|
+
throw new Error("Configuration reload is not part of the routed host contract");
|
|
345
409
|
const response = await this.#control.request({ type: "reload-config" });
|
|
346
410
|
if (response.type !== "config")
|
|
347
411
|
throw new Error("ghosttead returned an unexpected configuration response");
|
|
@@ -608,6 +672,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
608
672
|
}
|
|
609
673
|
async createSession(options) {
|
|
610
674
|
await this.connect();
|
|
675
|
+
if (this.#routedHost) {
|
|
676
|
+
if (!this.#routedHost.createSession)
|
|
677
|
+
throw new Error("The routed host does not provide session creation");
|
|
678
|
+
const session = await this.#routedHost.createSession(options);
|
|
679
|
+
this.registerSession(session);
|
|
680
|
+
return session;
|
|
681
|
+
}
|
|
611
682
|
if (options.scrollbackBytes !== undefined) {
|
|
612
683
|
if (!isValidScrollbackBytes(options.scrollbackBytes)) {
|
|
613
684
|
throw new RangeError("scrollbackBytes must be a non-negative safe integer");
|
|
@@ -628,6 +699,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
628
699
|
}
|
|
629
700
|
async listSessions() {
|
|
630
701
|
await this.connect();
|
|
702
|
+
if (this.#routedHost) {
|
|
703
|
+
const sessions = this.#routedHost.listSessions
|
|
704
|
+
? await this.#routedHost.listSessions()
|
|
705
|
+
: [...this.#sessionByHandle.values()];
|
|
706
|
+
for (const session of sessions)
|
|
707
|
+
this.registerSession(session);
|
|
708
|
+
return sessions;
|
|
709
|
+
}
|
|
631
710
|
const response = await this.#control.request({ type: "list-sessions" });
|
|
632
711
|
if (response.type !== "sessions")
|
|
633
712
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -706,6 +785,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
706
785
|
}
|
|
707
786
|
async listRemoteHosts() {
|
|
708
787
|
await this.connect();
|
|
788
|
+
if (this.#routedHost)
|
|
789
|
+
throw new Error("Remote-host discovery is not part of the routed host contract");
|
|
709
790
|
const response = await this.#control.request({ type: "list-remote-hosts" });
|
|
710
791
|
if (response.type !== "remote-hosts")
|
|
711
792
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -713,6 +794,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
713
794
|
}
|
|
714
795
|
async listRemoteSessions(deviceId) {
|
|
715
796
|
await this.connect();
|
|
797
|
+
if (this.#routedHost)
|
|
798
|
+
throw new Error("Remote-session discovery is not part of the routed host contract");
|
|
716
799
|
const response = await this.#control.request({ type: "list-remote-sessions", deviceId }, 35_000);
|
|
717
800
|
if (response.type !== "remote-sessions" || response.deviceId !== deviceId)
|
|
718
801
|
throw new Error("ghosttead returned an unexpected response");
|
|
@@ -720,6 +803,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
720
803
|
}
|
|
721
804
|
async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
|
|
722
805
|
await this.connect();
|
|
806
|
+
if (this.#routedHost)
|
|
807
|
+
throw new Error("Remote-session opening is not part of the routed host contract");
|
|
723
808
|
const response = await this.#control.request({
|
|
724
809
|
type: "open-remote-session",
|
|
725
810
|
deviceId,
|
|
@@ -767,6 +852,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
767
852
|
mount(sessionId, sessionHandle, viewId, canvas) {
|
|
768
853
|
if (this.#disposed)
|
|
769
854
|
throw new Error("Cannot mount a disposed terminal runtime");
|
|
855
|
+
if (this.#routedHost)
|
|
856
|
+
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
|
|
770
857
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
771
858
|
if (mounted) {
|
|
772
859
|
if (!mounted.active)
|
|
@@ -801,6 +888,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
801
888
|
const view = {
|
|
802
889
|
sessionId,
|
|
803
890
|
sessionHandle,
|
|
891
|
+
clientReadWrite: true,
|
|
892
|
+
resizeControlRequested: false,
|
|
893
|
+
visible: true,
|
|
804
894
|
inputSequence: 0,
|
|
805
895
|
resizeSequence: 0,
|
|
806
896
|
controlEpoch: undefined,
|
|
@@ -869,6 +959,711 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
869
959
|
.catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
|
|
870
960
|
return this.#createMountLease(entry);
|
|
871
961
|
}
|
|
962
|
+
#mountRouted(sessionId, sessionHandle, viewId, canvas) {
|
|
963
|
+
const mounted = this.#mountedCanvases.get(canvas);
|
|
964
|
+
if (mounted) {
|
|
965
|
+
if (!mounted.active)
|
|
966
|
+
throw new Error("A released terminal canvas cannot be remounted");
|
|
967
|
+
if (mounted.sessionHandle !== sessionHandle) {
|
|
968
|
+
throw new Error("A terminal canvas cannot be reassigned to another session");
|
|
969
|
+
}
|
|
970
|
+
mounted.references += 1;
|
|
971
|
+
if (mounted.disposeTimer !== undefined) {
|
|
972
|
+
window.clearTimeout(mounted.disposeTimer);
|
|
973
|
+
mounted.disposeTimer = undefined;
|
|
974
|
+
}
|
|
975
|
+
return this.#createMountLease(mounted);
|
|
976
|
+
}
|
|
977
|
+
const offscreen = canvas.transferControlToOffscreen();
|
|
978
|
+
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
979
|
+
this.#mountGenerationBySurface.set(viewId, generation);
|
|
980
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
981
|
+
const entry = {
|
|
982
|
+
canvas,
|
|
983
|
+
sessionHandle,
|
|
984
|
+
sessionId,
|
|
985
|
+
viewId,
|
|
986
|
+
generation,
|
|
987
|
+
references: 1,
|
|
988
|
+
disposeTimer: undefined,
|
|
989
|
+
active: true,
|
|
990
|
+
};
|
|
991
|
+
this.#mountedCanvases.set(canvas, entry);
|
|
992
|
+
this.#mountedEntries.add(entry);
|
|
993
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
994
|
+
this.#views.set(viewId, {
|
|
995
|
+
sessionId,
|
|
996
|
+
sessionHandle,
|
|
997
|
+
...(session === undefined ? {} : { readWrite: session.readWrite }),
|
|
998
|
+
clientReadWrite: true,
|
|
999
|
+
resizeControlRequested: false,
|
|
1000
|
+
visible: true,
|
|
1001
|
+
inputSequence: 0,
|
|
1002
|
+
resizeSequence: 0,
|
|
1003
|
+
controlEpoch: undefined,
|
|
1004
|
+
desiredCols: undefined,
|
|
1005
|
+
desiredRows: undefined,
|
|
1006
|
+
pendingInput: [],
|
|
1007
|
+
lastViewStateSeq: undefined,
|
|
1008
|
+
lastAttachmentEpoch: undefined,
|
|
1009
|
+
claimedEpoch: undefined,
|
|
1010
|
+
claimedRevision: 0,
|
|
1011
|
+
});
|
|
1012
|
+
const activation = this.#routedBySession.get(sessionId);
|
|
1013
|
+
if (activation)
|
|
1014
|
+
activation.viewIds.add(viewId);
|
|
1015
|
+
void this.#ensureRoutedActivation(sessionId, sessionHandle, viewId).catch((error) => {
|
|
1016
|
+
if (!this.#disposed)
|
|
1017
|
+
console.error(`[terminal-runtime] routed activation failed for ${sessionId}`, error);
|
|
1018
|
+
});
|
|
1019
|
+
return this.#createMountLease(entry);
|
|
1020
|
+
}
|
|
1021
|
+
async #ensureRoutedActivation(sessionId, sessionHandle, viewId) {
|
|
1022
|
+
const existing = this.#routedBySession.get(sessionId);
|
|
1023
|
+
if (existing) {
|
|
1024
|
+
existing.viewIds.add(viewId);
|
|
1025
|
+
if (existing.start)
|
|
1026
|
+
await existing.start;
|
|
1027
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1028
|
+
[...existing.viewIds].some((candidate) => {
|
|
1029
|
+
const view = this.#views.get(candidate);
|
|
1030
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1031
|
+
});
|
|
1032
|
+
this.#transitionRouted(existing, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
1033
|
+
this.#declareRoutedDemand(existing);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const activationId = crypto.randomUUID();
|
|
1037
|
+
const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
|
|
1038
|
+
this.#views.get(viewId)?.clientReadWrite !== false &&
|
|
1039
|
+
this.#views.get(viewId)?.readWrite !== false
|
|
1040
|
+
? "read-write"
|
|
1041
|
+
: "read-only";
|
|
1042
|
+
const entry = {
|
|
1043
|
+
sessionId,
|
|
1044
|
+
sessionHandle,
|
|
1045
|
+
state: initialRoutedActivation(sessionId, activationId, inputPolicy),
|
|
1046
|
+
viewIds: new Set([viewId]),
|
|
1047
|
+
recoveryAttempts: 0,
|
|
1048
|
+
preAuthRemints: { control: 0, frames: 0 },
|
|
1049
|
+
protocolFailures: { control: 0, frames: 0 },
|
|
1050
|
+
};
|
|
1051
|
+
this.#routedBySession.set(sessionId, entry);
|
|
1052
|
+
this.#routedByActivation.set(activationId, entry);
|
|
1053
|
+
entry.start = this.#startRoutedActivation(entry, "mount");
|
|
1054
|
+
try {
|
|
1055
|
+
await entry.start;
|
|
1056
|
+
}
|
|
1057
|
+
finally {
|
|
1058
|
+
delete entry.start;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
#routedTicketMatches(entry, ticket) {
|
|
1062
|
+
return (isRoutedTerminalOpenTicket(ticket) &&
|
|
1063
|
+
ticket.route.cellBootId === ticket.transportGrant.claims.audienceCellBootId &&
|
|
1064
|
+
ticket.route.cellBootId === ticket.attachGrant.claims.audienceCellBootId &&
|
|
1065
|
+
ticket.route.cellBootId === ticket.transportGrant.protected.kid.cellBootId &&
|
|
1066
|
+
ticket.route.cellBootId === ticket.attachGrant.protected.kid.cellBootId &&
|
|
1067
|
+
ticket.transportGrant.claims.clientId === ticket.attachGrant.claims.clientId &&
|
|
1068
|
+
ticket.transportGrant.claims.allowedChannels.includes("control") &&
|
|
1069
|
+
ticket.transportGrant.claims.allowedChannels.includes("frames") &&
|
|
1070
|
+
ticket.attachGrant.claims.sessionId === entry.sessionId &&
|
|
1071
|
+
ticket.attachGrant.claims.routeRevision === ticket.route.routeRevision &&
|
|
1072
|
+
(ticket.route.leaseEpoch === undefined ||
|
|
1073
|
+
ticket.attachGrant.claims.leaseEpoch === undefined ||
|
|
1074
|
+
ticket.route.leaseEpoch === ticket.attachGrant.claims.leaseEpoch));
|
|
1075
|
+
}
|
|
1076
|
+
#routedRenewalMatches(entry, value, previousGeneration) {
|
|
1077
|
+
const ticket = entry.ticket;
|
|
1078
|
+
return (ticket !== undefined &&
|
|
1079
|
+
isRoutedSessionAttachGrant(value) &&
|
|
1080
|
+
value.protected.kid.cellBootId === ticket.route.cellBootId &&
|
|
1081
|
+
value.claims.audienceCellBootId === ticket.route.cellBootId &&
|
|
1082
|
+
value.claims.clientId === ticket.attachGrant.claims.clientId &&
|
|
1083
|
+
value.claims.sessionId === entry.sessionId &&
|
|
1084
|
+
value.claims.routeRevision === ticket.route.routeRevision &&
|
|
1085
|
+
value.claims.leaseEpoch === ticket.attachGrant.claims.leaseEpoch &&
|
|
1086
|
+
value.claims.grantGeneration > previousGeneration);
|
|
1087
|
+
}
|
|
1088
|
+
async #startRoutedActivation(entry, reason) {
|
|
1089
|
+
const host = this.#routedHost;
|
|
1090
|
+
if (!host || this.#disposed || entry.viewIds.size === 0)
|
|
1091
|
+
return;
|
|
1092
|
+
let ticket;
|
|
1093
|
+
try {
|
|
1094
|
+
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
|
|
1101
|
+
return;
|
|
1102
|
+
if (!this.#routedTicketMatches(entry, ticket)) {
|
|
1103
|
+
this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
this.#transitionRouted(entry, { type: "ticket-minted", endpointsPresent: ticket.endpoints !== undefined });
|
|
1107
|
+
if (!ticket.endpoints)
|
|
1108
|
+
return;
|
|
1109
|
+
entry.ticket = ticket;
|
|
1110
|
+
this.#transitionRouted(entry, { type: "transport-ready" });
|
|
1111
|
+
this.#routedControl.attach({
|
|
1112
|
+
cellBootId: ticket.route.cellBootId,
|
|
1113
|
+
controlUrl: ticket.endpoints.controlUrl,
|
|
1114
|
+
transportGrant: ticket.transportGrant,
|
|
1115
|
+
attachGrant: ticket.attachGrant,
|
|
1116
|
+
activationId: entry.state.activationId,
|
|
1117
|
+
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1118
|
+
initialDemand: this.#routedDemand(entry),
|
|
1119
|
+
capabilities: this.#routedCapabilities,
|
|
1120
|
+
});
|
|
1121
|
+
this.#postWorker({
|
|
1122
|
+
type: "routed-frames-attach",
|
|
1123
|
+
request: {
|
|
1124
|
+
cellBootId: ticket.route.cellBootId,
|
|
1125
|
+
sessionHandle: entry.sessionHandle,
|
|
1126
|
+
framesUrl: ticket.endpoints.framesUrl,
|
|
1127
|
+
transportGrant: ticket.transportGrant,
|
|
1128
|
+
attachGrant: ticket.attachGrant,
|
|
1129
|
+
activationId: entry.state.activationId,
|
|
1130
|
+
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1131
|
+
...(this.#routedReceiverCapacities === undefined ? {} : { receiverCapacities: this.#routedReceiverCapacities }),
|
|
1132
|
+
capabilities: this.#routedCapabilities,
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1136
|
+
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1137
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1138
|
+
}
|
|
1139
|
+
#stopRoutedActivation(entry) {
|
|
1140
|
+
this.#routedControl?.detach(entry.state.activationId);
|
|
1141
|
+
this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
|
|
1142
|
+
if (entry.attachTimer !== undefined)
|
|
1143
|
+
window.clearTimeout(entry.attachTimer);
|
|
1144
|
+
delete entry.attachTimer;
|
|
1145
|
+
if (entry.renewalTimer !== undefined)
|
|
1146
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1147
|
+
delete entry.renewalTimer;
|
|
1148
|
+
delete entry.ticket;
|
|
1149
|
+
}
|
|
1150
|
+
#transitionRouted(entry, event) {
|
|
1151
|
+
const previous = entry.state;
|
|
1152
|
+
const next = reduceRoutedActivation(previous, event);
|
|
1153
|
+
if (next === previous)
|
|
1154
|
+
return;
|
|
1155
|
+
entry.state = next;
|
|
1156
|
+
if (next.phase !== "attaching" && entry.attachTimer !== undefined) {
|
|
1157
|
+
window.clearTimeout(entry.attachTimer);
|
|
1158
|
+
delete entry.attachTimer;
|
|
1159
|
+
}
|
|
1160
|
+
if ((next.phase === "unavailable" || next.phase === "ended") && next.phase !== previous.phase) {
|
|
1161
|
+
this.#stopRoutedActivation(entry);
|
|
1162
|
+
}
|
|
1163
|
+
if (!previous.presentationReady && next.presentationReady) {
|
|
1164
|
+
entry.recoveryAttempts = 0;
|
|
1165
|
+
entry.protocolFailures.control = 0;
|
|
1166
|
+
entry.protocolFailures.frames = 0;
|
|
1167
|
+
}
|
|
1168
|
+
this.dispatchEvent(new CustomEvent("routed-activation-state", {
|
|
1169
|
+
detail: { sessionId: entry.sessionId, previous, current: next },
|
|
1170
|
+
}));
|
|
1171
|
+
if (previous.presentationReady !== next.presentationReady || previous.inputAllowed !== next.inputAllowed) {
|
|
1172
|
+
for (const viewId of entry.viewIds) {
|
|
1173
|
+
const view = this.#views.get(viewId);
|
|
1174
|
+
this.dispatchEvent(new CustomEvent("routed-view-readiness", {
|
|
1175
|
+
detail: {
|
|
1176
|
+
sessionId: entry.sessionId,
|
|
1177
|
+
viewId,
|
|
1178
|
+
presentationReady: next.presentationReady,
|
|
1179
|
+
inputAllowed: next.inputAllowed && view?.clientReadWrite === true && view.readWrite !== false,
|
|
1180
|
+
phase: next.phase,
|
|
1181
|
+
},
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
#handleRoutedControlEvent(event) {
|
|
1187
|
+
if (this.#disposed)
|
|
1188
|
+
return;
|
|
1189
|
+
if (event.type === "transport-ready") {
|
|
1190
|
+
const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs;
|
|
1191
|
+
this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline);
|
|
1192
|
+
for (const entry of this.#routedBySession.values()) {
|
|
1193
|
+
if (entry.ticket?.route.cellBootId === event.cellBootId && entry.state.phase === "attaching") {
|
|
1194
|
+
this.#armRoutedAttachDeadline(entry, deadline);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (event.type === "control-attached") {
|
|
1200
|
+
const entry = this.#routedByActivation.get(event.attached.activationId);
|
|
1201
|
+
if (!entry || event.attached.sessionId !== entry.sessionId)
|
|
1202
|
+
return;
|
|
1203
|
+
entry.preAuthRemints.control = 0;
|
|
1204
|
+
this.#transitionRouted(entry, {
|
|
1205
|
+
type: "control-attached",
|
|
1206
|
+
grantGeneration: event.attached.grantGenerationAccepted,
|
|
1207
|
+
rights: event.attached.rights,
|
|
1208
|
+
});
|
|
1209
|
+
const previousGeometry = this.#routedGeometry.get(entry.sessionId);
|
|
1210
|
+
if (!event.attached.rights.includes("geometry") &&
|
|
1211
|
+
previousGeometry?.holderViewId !== undefined &&
|
|
1212
|
+
entry.viewIds.has(previousGeometry.holderViewId)) {
|
|
1213
|
+
// The cell auto-releases a holder when renewal drops the geometry
|
|
1214
|
+
// right. Preserve its next CAS revision without retaining authority.
|
|
1215
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1216
|
+
revision: previousGeometry.revision + 1,
|
|
1217
|
+
...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
|
|
1218
|
+
...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
1222
|
+
for (const viewId of entry.viewIds) {
|
|
1223
|
+
const view = this.#views.get(viewId);
|
|
1224
|
+
if (view?.resizeControlRequested &&
|
|
1225
|
+
geometry?.holderViewId !== viewId &&
|
|
1226
|
+
view.desiredCols !== undefined &&
|
|
1227
|
+
view.desiredRows !== undefined) {
|
|
1228
|
+
this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (event.type === "attach-refused") {
|
|
1234
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1235
|
+
if (!entry)
|
|
1236
|
+
return;
|
|
1237
|
+
this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
|
|
1238
|
+
if (entry.state.phase === "recovering") {
|
|
1239
|
+
this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry");
|
|
1240
|
+
}
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (event.type === "cell-status") {
|
|
1244
|
+
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1245
|
+
if (!entry || event.status.sessionId !== entry.sessionId)
|
|
1246
|
+
return;
|
|
1247
|
+
this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
|
|
1248
|
+
const sequence = entry.state.lastCellStatusSequence;
|
|
1249
|
+
window.setTimeout(() => {
|
|
1250
|
+
if (entry.state.lastCellStatusSequence !== sequence)
|
|
1251
|
+
return;
|
|
1252
|
+
this.#transitionRouted(entry, { type: "cell-lease-expired" });
|
|
1253
|
+
}, event.status.leaseTtlMs);
|
|
1254
|
+
if (event.status.presentation.state === "revoked" &&
|
|
1255
|
+
(event.status.presentation.reason === "leg-dead" || event.status.presentation.reason === "stale-route")) {
|
|
1256
|
+
this.#recoverRoutedActivation(entry, event.status.presentation.reason === "stale-route" ? "route-stale" : "retry");
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (event.type === "geometry-committed") {
|
|
1261
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1262
|
+
if (!entry)
|
|
1263
|
+
return;
|
|
1264
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1265
|
+
holderViewId: event.committed.holder.viewId,
|
|
1266
|
+
holderGeneration: event.committed.holder.holderGeneration,
|
|
1267
|
+
revision: event.committed.geometryRevision,
|
|
1268
|
+
cols: event.committed.cols,
|
|
1269
|
+
rows: event.committed.rows,
|
|
1270
|
+
});
|
|
1271
|
+
this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } }));
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (event.type === "geometry-refused") {
|
|
1275
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1276
|
+
if (entry && event.refused.geometryRevision !== undefined) {
|
|
1277
|
+
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
1278
|
+
const holder = event.refused.currentHolder;
|
|
1279
|
+
this.#routedGeometry.set(entry.sessionId, {
|
|
1280
|
+
revision: event.refused.geometryRevision,
|
|
1281
|
+
...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
|
|
1282
|
+
...(previous?.cols === undefined ? {} : { cols: previous.cols }),
|
|
1283
|
+
...(previous?.rows === undefined ? {} : { rows: previous.rows }),
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event }));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (event.type === "transport-closed") {
|
|
1290
|
+
this.#routedAttachDeadlineByCell.delete(event.cellBootId);
|
|
1291
|
+
for (const activationId of event.activationIds) {
|
|
1292
|
+
const entry = this.#routedByActivation.get(activationId);
|
|
1293
|
+
if (!entry)
|
|
1294
|
+
continue;
|
|
1295
|
+
if (event.preAuth || event.refusal) {
|
|
1296
|
+
const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
|
|
1297
|
+
this.#transitionRouted(entry, {
|
|
1298
|
+
type: "transport-failed",
|
|
1299
|
+
...(event.preAuth ? { preAuth: true } : {}),
|
|
1300
|
+
...(event.refusal === undefined ? {} : { retryable: recoverable }),
|
|
1301
|
+
});
|
|
1302
|
+
if (entry.state.phase === "unavailable")
|
|
1303
|
+
continue;
|
|
1304
|
+
this.#recoverRoutedActivation(entry, event.preAuth ? "pre-auth" : "retry", "control");
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
const routeStale = event.code === 4000 || event.code === 4001;
|
|
1308
|
+
if (event.code === 4002) {
|
|
1309
|
+
this.#transitionRouted(entry, { type: "replaced" });
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
if (event.code === 4003) {
|
|
1313
|
+
if (entry.protocolFailures.control >= 1) {
|
|
1314
|
+
this.#transitionRouted(entry, { type: "leg-lost", channel: "control", resumeCapable: false });
|
|
1315
|
+
this.#transitionRouted(entry, {
|
|
1316
|
+
type: "transport-failed",
|
|
1317
|
+
recoveryExhausted: true,
|
|
1318
|
+
reason: "protocol",
|
|
1319
|
+
});
|
|
1320
|
+
this.dispatchEvent(new CustomEvent("routed-protocol-error", {
|
|
1321
|
+
detail: { sessionId: entry.sessionId, channel: "control", reason: event.reason },
|
|
1322
|
+
}));
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
entry.protocolFailures.control += 1;
|
|
1326
|
+
}
|
|
1327
|
+
this.#transitionRouted(entry, {
|
|
1328
|
+
type: routeStale ? "route-stale" : "leg-lost",
|
|
1329
|
+
channel: "control",
|
|
1330
|
+
resumeCapable: false,
|
|
1331
|
+
});
|
|
1332
|
+
this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "control");
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
#handleRoutedFramesEvent(event) {
|
|
1337
|
+
if (event.type === "frames-attached") {
|
|
1338
|
+
const entry = this.#routedByActivation.get(event.attached.activationId);
|
|
1339
|
+
if (!entry || event.attached.sessionId !== entry.sessionId)
|
|
1340
|
+
return;
|
|
1341
|
+
entry.preAuthRemints.frames = 0;
|
|
1342
|
+
this.#transitionRouted(entry, {
|
|
1343
|
+
type: "frames-attached",
|
|
1344
|
+
outcome: event.attached.outcome,
|
|
1345
|
+
trfIdentity: event.attached.trfIdentity,
|
|
1346
|
+
resumeToken: event.attached.resumeToken,
|
|
1347
|
+
});
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (event.type === "attach-refused") {
|
|
1351
|
+
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1352
|
+
if (!entry)
|
|
1353
|
+
return;
|
|
1354
|
+
this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
|
|
1355
|
+
if (entry.state.phase === "recovering") {
|
|
1356
|
+
this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", "frames");
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (event.type === "frames-state") {
|
|
1361
|
+
const entry = this.#routedByActivation.get(event.activationId);
|
|
1362
|
+
if (!entry)
|
|
1363
|
+
return;
|
|
1364
|
+
this.#transitionRouted(entry, {
|
|
1365
|
+
type: "frames-state",
|
|
1366
|
+
state: {
|
|
1367
|
+
activationId: event.activationId,
|
|
1368
|
+
state: event.state,
|
|
1369
|
+
...(event.resumeToken === undefined ? {} : { resumeToken: event.resumeToken }),
|
|
1370
|
+
...(event.appliedContent === undefined ? {} : { appliedContent: event.appliedContent }),
|
|
1371
|
+
},
|
|
1372
|
+
});
|
|
1373
|
+
if (event.state === "active" && event.appliedContent) {
|
|
1374
|
+
this.#transitionRouted(entry, { type: "sync-complete", appliedContent: event.appliedContent });
|
|
1375
|
+
}
|
|
1376
|
+
else if (event.state === "failed") {
|
|
1377
|
+
this.#transitionRouted(entry, { type: "sync-failed" });
|
|
1378
|
+
}
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (event.type === "presentation-status") {
|
|
1382
|
+
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1383
|
+
if (!entry)
|
|
1384
|
+
return;
|
|
1385
|
+
this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
|
|
1386
|
+
const sequence = entry.state.lastWorkerStatusSequence;
|
|
1387
|
+
window.setTimeout(() => {
|
|
1388
|
+
if (entry.state.lastWorkerStatusSequence !== sequence)
|
|
1389
|
+
return;
|
|
1390
|
+
this.#transitionRouted(entry, { type: "worker-lease-expired" });
|
|
1391
|
+
}, event.status.leaseTtlMs);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
for (const activationId of event.activationIds) {
|
|
1395
|
+
const entry = this.#routedByActivation.get(activationId);
|
|
1396
|
+
if (!entry)
|
|
1397
|
+
continue;
|
|
1398
|
+
if (event.preAuth || event.refusal) {
|
|
1399
|
+
const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
|
|
1400
|
+
this.#transitionRouted(entry, {
|
|
1401
|
+
type: "transport-failed",
|
|
1402
|
+
...(event.preAuth ? { preAuth: true } : {}),
|
|
1403
|
+
...(event.refusal === undefined ? {} : { retryable: recoverable }),
|
|
1404
|
+
});
|
|
1405
|
+
if (entry.state.phase === "unavailable")
|
|
1406
|
+
continue;
|
|
1407
|
+
this.#resumeRoutedFrames(entry, event.preAuth ? "pre-auth" : "retry");
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
const routeStale = event.code === 4000 || event.code === 4001;
|
|
1411
|
+
if (event.code === 4002) {
|
|
1412
|
+
this.#transitionRouted(entry, { type: "replaced" });
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
if (event.code === 4003) {
|
|
1416
|
+
if (entry.protocolFailures.frames >= 1) {
|
|
1417
|
+
this.#transitionRouted(entry, { type: "leg-lost", channel: "frames", resumeCapable: false });
|
|
1418
|
+
this.#transitionRouted(entry, {
|
|
1419
|
+
type: "transport-failed",
|
|
1420
|
+
recoveryExhausted: true,
|
|
1421
|
+
reason: "protocol",
|
|
1422
|
+
});
|
|
1423
|
+
this.dispatchEvent(new CustomEvent("routed-protocol-error", {
|
|
1424
|
+
detail: { sessionId: entry.sessionId, channel: "frames", reason: event.reason },
|
|
1425
|
+
}));
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
entry.protocolFailures.frames += 1;
|
|
1429
|
+
}
|
|
1430
|
+
const activationFailed = event.code === 4002 || event.code === 4003;
|
|
1431
|
+
const canResume = !routeStale &&
|
|
1432
|
+
!activationFailed &&
|
|
1433
|
+
this.#routedCapabilities.includes("resume") &&
|
|
1434
|
+
entry.state.resumeToken !== undefined &&
|
|
1435
|
+
entry.state.appliedContent !== undefined &&
|
|
1436
|
+
entry.ticket?.endpoints !== undefined;
|
|
1437
|
+
this.#transitionRouted(entry, {
|
|
1438
|
+
type: routeStale ? "route-stale" : "leg-lost",
|
|
1439
|
+
channel: "frames",
|
|
1440
|
+
resumeCapable: canResume,
|
|
1441
|
+
});
|
|
1442
|
+
if (canResume) {
|
|
1443
|
+
this.#resumeRoutedFrames(entry, "retry");
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "frames");
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
#resumeRoutedFrames(entry, reason) {
|
|
1451
|
+
if (entry.framesResume || this.#disposed)
|
|
1452
|
+
return;
|
|
1453
|
+
const task = (async () => {
|
|
1454
|
+
const host = this.#routedHost;
|
|
1455
|
+
const previousTicket = entry.ticket;
|
|
1456
|
+
const activationId = entry.state.activationId;
|
|
1457
|
+
const resumeToken = entry.state.resumeToken;
|
|
1458
|
+
const appliedContent = entry.state.appliedContent;
|
|
1459
|
+
if (!host || !previousTicket?.endpoints || !resumeToken || !appliedContent) {
|
|
1460
|
+
this.#recoverRoutedActivation(entry, reason, "frames");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (reason === "pre-auth") {
|
|
1464
|
+
if (entry.preAuthRemints.frames >= 1) {
|
|
1465
|
+
this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
entry.preAuthRemints.frames += 1;
|
|
1469
|
+
}
|
|
1470
|
+
entry.recoveryAttempts += 1;
|
|
1471
|
+
if (entry.recoveryAttempts > 5) {
|
|
1472
|
+
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
let ticket;
|
|
1476
|
+
try {
|
|
1477
|
+
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
this.#recoverRoutedActivation(entry, "retry", "frames");
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (this.#disposed ||
|
|
1484
|
+
this.#routedByActivation.get(activationId) !== entry ||
|
|
1485
|
+
entry.state.activationId !== activationId) {
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (!this.#routedTicketMatches(entry, ticket) || !ticket.endpoints) {
|
|
1489
|
+
this.#recoverRoutedActivation(entry, "route-stale", "frames");
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const sameRoute = ticket.route.cellBootId === previousTicket.route.cellBootId &&
|
|
1493
|
+
ticket.route.routeRevision === previousTicket.route.routeRevision &&
|
|
1494
|
+
ticket.route.leaseEpoch === previousTicket.route.leaseEpoch;
|
|
1495
|
+
if (!sameRoute) {
|
|
1496
|
+
this.#transitionRouted(entry, { type: "route-stale" });
|
|
1497
|
+
this.#recoverRoutedActivation(entry, "route-stale", "frames");
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
entry.ticket = ticket;
|
|
1501
|
+
this.#routedControl?.renew(activationId, ticket.attachGrant);
|
|
1502
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1503
|
+
this.#postWorker({
|
|
1504
|
+
type: "routed-frames-attach",
|
|
1505
|
+
request: {
|
|
1506
|
+
cellBootId: ticket.route.cellBootId,
|
|
1507
|
+
sessionHandle: entry.sessionHandle,
|
|
1508
|
+
framesUrl: ticket.endpoints.framesUrl,
|
|
1509
|
+
transportGrant: ticket.transportGrant,
|
|
1510
|
+
attachGrant: ticket.attachGrant,
|
|
1511
|
+
activationId,
|
|
1512
|
+
resume: { resumeToken, from: appliedContent },
|
|
1513
|
+
...(this.#routedReceiverCapacities === undefined
|
|
1514
|
+
? {}
|
|
1515
|
+
: { receiverCapacities: this.#routedReceiverCapacities }),
|
|
1516
|
+
capabilities: this.#routedCapabilities,
|
|
1517
|
+
},
|
|
1518
|
+
});
|
|
1519
|
+
})();
|
|
1520
|
+
entry.framesResume = task;
|
|
1521
|
+
void task.finally(() => {
|
|
1522
|
+
if (entry.framesResume === task)
|
|
1523
|
+
delete entry.framesResume;
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
#recoverRoutedActivation(entry, reason, failedChannel) {
|
|
1527
|
+
if (this.#disposed || entry.viewIds.size === 0 || entry.state.phase === "ended")
|
|
1528
|
+
return;
|
|
1529
|
+
if (reason === "pre-auth") {
|
|
1530
|
+
const channel = failedChannel ?? "control";
|
|
1531
|
+
if (entry.preAuthRemints[channel] >= 1) {
|
|
1532
|
+
this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
entry.preAuthRemints[channel] += 1;
|
|
1536
|
+
}
|
|
1537
|
+
entry.recoveryAttempts += 1;
|
|
1538
|
+
if (entry.recoveryAttempts > 5) {
|
|
1539
|
+
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
// Geometry belongs to the cell-side attach/client/view scope. Preserve it
|
|
1543
|
+
// across a same-route leg replacement, but never carry its revision or
|
|
1544
|
+
// holder generation to a newly routed cell.
|
|
1545
|
+
if (reason === "route-stale")
|
|
1546
|
+
this.#routedGeometry.delete(entry.sessionId);
|
|
1547
|
+
const previousActivationId = entry.state.activationId;
|
|
1548
|
+
this.#routedControl?.detach(previousActivationId);
|
|
1549
|
+
this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
|
|
1550
|
+
this.#routedByActivation.delete(previousActivationId);
|
|
1551
|
+
const nextActivationId = crypto.randomUUID();
|
|
1552
|
+
entry.replacesActivationId = previousActivationId;
|
|
1553
|
+
const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
|
|
1554
|
+
[...entry.viewIds].some((viewId) => {
|
|
1555
|
+
const view = this.#views.get(viewId);
|
|
1556
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1557
|
+
})
|
|
1558
|
+
? "read-write"
|
|
1559
|
+
: "read-only";
|
|
1560
|
+
entry.state = {
|
|
1561
|
+
...initialRoutedActivation(entry.sessionId, nextActivationId, inputPolicy),
|
|
1562
|
+
phase: "recovering",
|
|
1563
|
+
replacesActivationId: previousActivationId,
|
|
1564
|
+
preAuthRemintUsed: entry.preAuthRemints.control > 0 || entry.preAuthRemints.frames > 0,
|
|
1565
|
+
};
|
|
1566
|
+
this.#routedByActivation.set(nextActivationId, entry);
|
|
1567
|
+
if (entry.attachTimer !== undefined)
|
|
1568
|
+
window.clearTimeout(entry.attachTimer);
|
|
1569
|
+
delete entry.attachTimer;
|
|
1570
|
+
if (entry.renewalTimer !== undefined)
|
|
1571
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1572
|
+
delete entry.renewalTimer;
|
|
1573
|
+
delete entry.ticket;
|
|
1574
|
+
const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
|
|
1575
|
+
window.setTimeout(() => {
|
|
1576
|
+
if (this.#routedByActivation.get(nextActivationId) !== entry)
|
|
1577
|
+
return;
|
|
1578
|
+
entry.start = this.#startRoutedActivation(entry, reason);
|
|
1579
|
+
void entry.start.finally(() => delete entry.start);
|
|
1580
|
+
}, delay);
|
|
1581
|
+
}
|
|
1582
|
+
#armRoutedAttachDeadline(entry, delayMs) {
|
|
1583
|
+
if (entry.attachTimer !== undefined)
|
|
1584
|
+
window.clearTimeout(entry.attachTimer);
|
|
1585
|
+
const activationId = entry.state.activationId;
|
|
1586
|
+
entry.attachTimer = window.setTimeout(() => {
|
|
1587
|
+
if (this.#routedByActivation.get(activationId) !== entry || entry.state.phase !== "attaching")
|
|
1588
|
+
return;
|
|
1589
|
+
this.#transitionRouted(entry, { type: "attach-deadline" });
|
|
1590
|
+
this.#recoverRoutedActivation(entry, "retry");
|
|
1591
|
+
}, Math.max(0, delayMs));
|
|
1592
|
+
}
|
|
1593
|
+
#scheduleRoutedRenewal(entry) {
|
|
1594
|
+
const ticket = entry.ticket;
|
|
1595
|
+
const host = this.#routedHost;
|
|
1596
|
+
if (!ticket || !host)
|
|
1597
|
+
return;
|
|
1598
|
+
if (entry.renewalTimer !== undefined)
|
|
1599
|
+
window.clearTimeout(entry.renewalTimer);
|
|
1600
|
+
const generation = ticket.attachGrant.claims.grantGeneration;
|
|
1601
|
+
const delay = Math.max(0, ticket.attachGrant.claims.expiresAt - Date.now() - 60_000);
|
|
1602
|
+
if (delay > MAX_BROWSER_TIMEOUT_MS) {
|
|
1603
|
+
entry.renewalTimer = window.setTimeout(() => {
|
|
1604
|
+
if (entry.ticket !== ticket || this.#disposed)
|
|
1605
|
+
return;
|
|
1606
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1607
|
+
}, MAX_BROWSER_TIMEOUT_MS);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
entry.renewalTimer = window.setTimeout(() => {
|
|
1611
|
+
this.#transitionRouted(entry, { type: "grant-expiring" });
|
|
1612
|
+
if (!host.renewAttach) {
|
|
1613
|
+
this.#transitionRouted(entry, { type: "renew-failed" });
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const requestId = crypto.randomUUID();
|
|
1617
|
+
void host
|
|
1618
|
+
.renewAttach({ sessionId: entry.sessionId, expectGeneration: generation, requestId })
|
|
1619
|
+
.then(({ attachGrant }) => {
|
|
1620
|
+
if (entry.ticket !== ticket || !this.#routedRenewalMatches(entry, attachGrant, generation)) {
|
|
1621
|
+
this.#transitionRouted(entry, { type: "renew-failed" });
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
entry.ticket = { ...ticket, attachGrant };
|
|
1625
|
+
this.#routedControl?.renew(entry.state.activationId, attachGrant);
|
|
1626
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1627
|
+
})
|
|
1628
|
+
.catch(() => this.#transitionRouted(entry, { type: "renew-failed" }));
|
|
1629
|
+
}, delay);
|
|
1630
|
+
}
|
|
1631
|
+
#routedDemand(entry) {
|
|
1632
|
+
let live = false;
|
|
1633
|
+
let urgent = false;
|
|
1634
|
+
for (const viewId of entry.viewIds) {
|
|
1635
|
+
const view = this.#views.get(viewId);
|
|
1636
|
+
live ||= view?.visible === true;
|
|
1637
|
+
urgent ||= view?.visible === true && this.#focusByView.get(viewId) === true;
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
mode: live ? "live" : "none",
|
|
1641
|
+
urgency: urgent ? "urgent" : "normal",
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
#declareRoutedDemand(entry) {
|
|
1645
|
+
this.#routedControl?.declareDemand(entry.state.activationId, this.#routedDemand(entry));
|
|
1646
|
+
}
|
|
1647
|
+
#releaseRoutedView(sessionId, viewId) {
|
|
1648
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
1649
|
+
if (!entry)
|
|
1650
|
+
return;
|
|
1651
|
+
entry.viewIds.delete(viewId);
|
|
1652
|
+
if (entry.viewIds.size > 0) {
|
|
1653
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1654
|
+
[...entry.viewIds].some((candidate) => {
|
|
1655
|
+
const view = this.#views.get(candidate);
|
|
1656
|
+
return view?.clientReadWrite === true && view.readWrite !== false;
|
|
1657
|
+
});
|
|
1658
|
+
this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
1659
|
+
this.#declareRoutedDemand(entry);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
this.#transitionRouted(entry, { type: "detach" });
|
|
1663
|
+
this.#routedBySession.delete(sessionId);
|
|
1664
|
+
this.#routedByActivation.delete(entry.state.activationId);
|
|
1665
|
+
this.#routedGeometry.delete(sessionId);
|
|
1666
|
+
}
|
|
872
1667
|
#createMountLease(mounted) {
|
|
873
1668
|
let disposed = false;
|
|
874
1669
|
return {
|
|
@@ -891,13 +1686,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
891
1686
|
if (ownsWorkerSurface) {
|
|
892
1687
|
this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
|
|
893
1688
|
this.#mountGenerationBySurface.delete(mounted.viewId);
|
|
894
|
-
this.#
|
|
1689
|
+
if (this.#routedHost)
|
|
1690
|
+
this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
|
|
1691
|
+
else
|
|
1692
|
+
this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
|
|
895
1693
|
this.#views.delete(mounted.viewId);
|
|
896
1694
|
this.#focusByView.delete(mounted.viewId);
|
|
897
1695
|
}
|
|
898
1696
|
this.#mountedCanvases.delete(mounted.canvas);
|
|
899
1697
|
this.#mountedEntries.delete(mounted);
|
|
900
|
-
this.#
|
|
1698
|
+
if (!this.#routedHost)
|
|
1699
|
+
this.#releaseFrameSubscription(mounted.sessionHandle);
|
|
901
1700
|
}, 0);
|
|
902
1701
|
},
|
|
903
1702
|
};
|
|
@@ -950,8 +1749,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
950
1749
|
this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
|
|
951
1750
|
}
|
|
952
1751
|
}
|
|
953
|
-
// A cleared controller is the one case worth re-evaluating:
|
|
954
|
-
//
|
|
1752
|
+
// A cleared controller is the one case worth re-evaluating: a view with an
|
|
1753
|
+
// outstanding explicit resize-control request may now take the seat.
|
|
955
1754
|
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
956
1755
|
this.#maybeReclaim(viewId);
|
|
957
1756
|
}
|
|
@@ -963,10 +1762,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
963
1762
|
}
|
|
964
1763
|
/**
|
|
965
1764
|
* 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.
|
|
1765
|
+
* gates an explicit claim re-enters here when it changes, because no one
|
|
1766
|
+
* event is enough: recovery can mark a view attached before its session
|
|
1767
|
+
* reaches live, while the resize-control request already exists.
|
|
970
1768
|
*
|
|
971
1769
|
* At most one claim per attachment epoch, plus one more each time the
|
|
972
1770
|
* controller is cleared at a newer revision.
|
|
@@ -980,15 +1778,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
980
1778
|
*/
|
|
981
1779
|
#maybeReclaim(viewId) {
|
|
982
1780
|
const view = this.#views.get(viewId);
|
|
983
|
-
if (!view || view.readWrite === false)
|
|
1781
|
+
if (!view || view.readWrite === false || !view.clientReadWrite || !view.resizeControlRequested)
|
|
984
1782
|
return;
|
|
985
1783
|
const attachmentEpoch = view.attachmentEpoch;
|
|
986
1784
|
if (attachmentEpoch === undefined)
|
|
987
1785
|
return;
|
|
988
1786
|
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
989
1787
|
return;
|
|
990
|
-
if (this.#focusByView.get(viewId) !== true)
|
|
991
|
-
return;
|
|
992
1788
|
const remote = this.#remoteSessions.get(view.sessionId);
|
|
993
1789
|
if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
|
|
994
1790
|
return;
|
|
@@ -1181,7 +1977,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1181
1977
|
*/
|
|
1182
1978
|
#sendViewInput(viewId, operation, silent = false) {
|
|
1183
1979
|
const view = this.#views.get(viewId);
|
|
1184
|
-
if (!view || view.readWrite === false)
|
|
1980
|
+
if (!view || view.readWrite === false || !view.clientReadWrite)
|
|
1185
1981
|
return;
|
|
1186
1982
|
const remote = this.#remoteSessions.get(view.sessionId);
|
|
1187
1983
|
const attachmentEpoch = view.attachmentEpoch;
|
|
@@ -1206,36 +2002,100 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1206
2002
|
#reportSuppressedInput(sessionId, viewId, state) {
|
|
1207
2003
|
this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
|
|
1208
2004
|
}
|
|
2005
|
+
#sendRoutedInput(sessionId, viewId, operation, silent = false) {
|
|
2006
|
+
const host = this.#routedHost;
|
|
2007
|
+
const view = this.#views.get(viewId);
|
|
2008
|
+
const activation = this.#routedBySession.get(sessionId);
|
|
2009
|
+
if (!host ||
|
|
2010
|
+
!view ||
|
|
2011
|
+
view.sessionId !== sessionId ||
|
|
2012
|
+
view.readWrite === false ||
|
|
2013
|
+
!view.clientReadWrite ||
|
|
2014
|
+
!activation?.state.inputAllowed) {
|
|
2015
|
+
if (!silent) {
|
|
2016
|
+
this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
|
|
2017
|
+
detail: {
|
|
2018
|
+
sessionId,
|
|
2019
|
+
viewId,
|
|
2020
|
+
reason: !host?.encodeInput ? "wire-verb-unavailable" : "input-not-allowed",
|
|
2021
|
+
},
|
|
2022
|
+
}));
|
|
2023
|
+
}
|
|
2024
|
+
return false;
|
|
2025
|
+
}
|
|
2026
|
+
if (!host.encodeInput) {
|
|
2027
|
+
if (!silent) {
|
|
2028
|
+
this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
|
|
2029
|
+
detail: { sessionId, viewId, reason: "wire-verb-unavailable" },
|
|
2030
|
+
}));
|
|
2031
|
+
}
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
view.inputSequence += 1;
|
|
2035
|
+
const message = host.encodeInput({
|
|
2036
|
+
sessionId,
|
|
2037
|
+
viewId,
|
|
2038
|
+
activationId: activation.state.activationId,
|
|
2039
|
+
leaseEpoch: activation.ticket?.attachGrant.claims.leaseEpoch ?? 0,
|
|
2040
|
+
inputSequence: view.inputSequence,
|
|
2041
|
+
operation,
|
|
2042
|
+
});
|
|
2043
|
+
return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
|
|
2044
|
+
}
|
|
1209
2045
|
sendText(sessionId, viewId, text) {
|
|
2046
|
+
if (this.#routedHost) {
|
|
2047
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
1210
2050
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
1211
2051
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1212
2052
|
if (handle)
|
|
1213
2053
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1214
2054
|
}
|
|
1215
2055
|
paste(sessionId, viewId, text) {
|
|
2056
|
+
if (this.#routedHost) {
|
|
2057
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
1216
2060
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "paste", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
1217
2061
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1218
2062
|
if (handle)
|
|
1219
2063
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1220
2064
|
}
|
|
1221
2065
|
sendKey(sessionId, viewId, event) {
|
|
2066
|
+
if (this.#routedHost) {
|
|
2067
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
1222
2070
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-key", sessionId, viewId, attachmentEpoch, inputSequence, event }));
|
|
1223
2071
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1224
2072
|
if (handle)
|
|
1225
2073
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
1226
2074
|
}
|
|
1227
2075
|
sendMouse(sessionId, viewId, event) {
|
|
2076
|
+
if (this.#routedHost) {
|
|
2077
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "mouse", event }, true);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
1228
2080
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
|
|
1229
2081
|
}
|
|
1230
2082
|
scroll(sessionId, viewId, rows) {
|
|
1231
2083
|
if (rows === 0)
|
|
1232
2084
|
return;
|
|
2085
|
+
if (this.#routedHost) {
|
|
2086
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "scroll", rows }, true);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
1233
2089
|
// Scrolling is host-side input, so it is simply inert while frozen.
|
|
1234
2090
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
|
|
1235
2091
|
}
|
|
1236
2092
|
scrollTo(sessionId, viewId, row) {
|
|
1237
2093
|
if (!Number.isSafeInteger(row) || row < 0)
|
|
1238
2094
|
return;
|
|
2095
|
+
if (this.#routedHost) {
|
|
2096
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "scroll-to", row }, true);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
1239
2099
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
|
|
1240
2100
|
}
|
|
1241
2101
|
scrollbar(sessionHandle) {
|
|
@@ -1246,6 +2106,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1246
2106
|
}
|
|
1247
2107
|
setTheme(sessionHandle, theme, surfaceId) {
|
|
1248
2108
|
this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
|
|
2109
|
+
// A surface-scoped theme is renderer-local. Updating the daemon's
|
|
2110
|
+
// session-wide palette here would repaint every mirrored viewer.
|
|
2111
|
+
if (surfaceId)
|
|
2112
|
+
return;
|
|
1249
2113
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
1250
2114
|
if (!session)
|
|
1251
2115
|
return;
|
|
@@ -1270,6 +2134,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1270
2134
|
}
|
|
1271
2135
|
setVisible(sessionHandle, visible, surfaceId) {
|
|
1272
2136
|
this.#postWorker({ type: "visibility", sessionHandle, ...(surfaceId ? { surfaceId } : {}), visible });
|
|
2137
|
+
if (this.#routedHost) {
|
|
2138
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2139
|
+
if (!session)
|
|
2140
|
+
return;
|
|
2141
|
+
if (surfaceId) {
|
|
2142
|
+
const view = this.#views.get(surfaceId);
|
|
2143
|
+
if (view)
|
|
2144
|
+
view.visible = visible;
|
|
2145
|
+
}
|
|
2146
|
+
else {
|
|
2147
|
+
for (const view of this.#views.values()) {
|
|
2148
|
+
if (view.sessionId === session.id)
|
|
2149
|
+
view.visible = visible;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
const entry = this.#routedBySession.get(session.id);
|
|
2153
|
+
if (entry)
|
|
2154
|
+
this.#declareRoutedDemand(entry);
|
|
2155
|
+
}
|
|
1273
2156
|
}
|
|
1274
2157
|
forceFullRedraw(sessionHandle) {
|
|
1275
2158
|
this.#postWorker({ type: "force-full-redraw", sessionHandle });
|
|
@@ -1280,28 +2163,92 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1280
2163
|
setPartialRenderingEnabled(enabled) {
|
|
1281
2164
|
this.#postWorker({ type: "partial-rendering", enabled });
|
|
1282
2165
|
}
|
|
2166
|
+
#claimRoutedGeometry(entry, viewId, cols, rows) {
|
|
2167
|
+
const ticket = entry.ticket;
|
|
2168
|
+
const view = this.#views.get(viewId);
|
|
2169
|
+
if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
|
|
2170
|
+
return;
|
|
2171
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2172
|
+
this.#routedControl?.claimGeometry(entry.state.activationId, {
|
|
2173
|
+
sessionId: entry.sessionId,
|
|
2174
|
+
activationId: entry.state.activationId,
|
|
2175
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2176
|
+
claimant: { clientId: ticket.attachGrant.claims.clientId, viewId },
|
|
2177
|
+
cols,
|
|
2178
|
+
rows,
|
|
2179
|
+
expectRevision: geometry?.revision ?? 0,
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
1283
2182
|
claimResizeControl(sessionHandle, viewId, cols, rows) {
|
|
1284
2183
|
const view = this.#views.get(viewId);
|
|
1285
2184
|
if (view) {
|
|
1286
2185
|
view.desiredCols = cols;
|
|
1287
2186
|
view.desiredRows = rows;
|
|
1288
|
-
|
|
1289
|
-
view.claimedEpoch = view.attachmentEpoch;
|
|
1290
|
-
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
2187
|
+
view.resizeControlRequested = true;
|
|
1291
2188
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
2189
|
+
if (this.#routedHost) {
|
|
2190
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2191
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
2192
|
+
if (entry)
|
|
2193
|
+
this.#claimRoutedGeometry(entry, viewId, cols, rows);
|
|
1294
2194
|
return;
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2195
|
+
}
|
|
2196
|
+
if (!this.#sessionByHandle.has(sessionHandle))
|
|
2197
|
+
return;
|
|
2198
|
+
this.#maybeReclaim(viewId);
|
|
2199
|
+
}
|
|
2200
|
+
releaseResizeControl(viewId) {
|
|
2201
|
+
const view = this.#views.get(viewId);
|
|
2202
|
+
if (!view)
|
|
2203
|
+
return;
|
|
2204
|
+
view.resizeControlRequested = false;
|
|
2205
|
+
if (this.#routedHost) {
|
|
2206
|
+
const entry = this.#routedBySession.get(view.sessionId);
|
|
2207
|
+
const geometry = this.#routedGeometry.get(view.sessionId);
|
|
2208
|
+
const ticket = entry?.ticket;
|
|
2209
|
+
if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) {
|
|
2210
|
+
const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
|
|
2211
|
+
sessionId: view.sessionId,
|
|
2212
|
+
activationId: entry.state.activationId,
|
|
2213
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2214
|
+
holder: {
|
|
2215
|
+
clientId: ticket.attachGrant.claims.clientId,
|
|
2216
|
+
viewId,
|
|
2217
|
+
holderGeneration: geometry.holderGeneration,
|
|
2218
|
+
},
|
|
2219
|
+
});
|
|
2220
|
+
if (sent) {
|
|
2221
|
+
// T1 sends no success body for release. The ordered control leg and
|
|
2222
|
+
// cell state machine make the next revision deterministic.
|
|
2223
|
+
this.#routedGeometry.set(view.sessionId, {
|
|
2224
|
+
revision: geometry.revision + 1,
|
|
2225
|
+
...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
|
|
2226
|
+
...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
// The legacy protocol has no release verb. Clearing the local epoch still
|
|
2233
|
+
// closes every resize path immediately; a later explicit claim can renew it.
|
|
2234
|
+
view.controlEpoch = undefined;
|
|
2235
|
+
}
|
|
2236
|
+
setViewInputPolicy(viewId, readWrite) {
|
|
2237
|
+
const view = this.#views.get(viewId);
|
|
2238
|
+
if (!view)
|
|
2239
|
+
return;
|
|
2240
|
+
view.clientReadWrite = readWrite;
|
|
2241
|
+
if (!readWrite)
|
|
2242
|
+
view.pendingInput.length = 0;
|
|
2243
|
+
const entry = this.#routedBySession.get(view.sessionId);
|
|
2244
|
+
if (entry) {
|
|
2245
|
+
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
2246
|
+
[...entry.viewIds].some((candidate) => {
|
|
2247
|
+
const candidateView = this.#views.get(candidate);
|
|
2248
|
+
return candidateView?.clientReadWrite === true && candidateView.readWrite !== false;
|
|
2249
|
+
});
|
|
2250
|
+
this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
|
|
2251
|
+
}
|
|
1305
2252
|
}
|
|
1306
2253
|
setFocused(sessionHandle, viewId, focused, cols, rows) {
|
|
1307
2254
|
const view = this.#views.get(viewId);
|
|
@@ -1310,10 +2257,6 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1310
2257
|
view.desiredRows = rows;
|
|
1311
2258
|
}
|
|
1312
2259
|
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
2260
|
return;
|
|
1318
2261
|
}
|
|
1319
2262
|
this.#focusByView.set(viewId, focused);
|
|
@@ -1321,6 +2264,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1321
2264
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
1322
2265
|
if (!session)
|
|
1323
2266
|
return;
|
|
2267
|
+
if (this.#routedHost) {
|
|
2268
|
+
const entry = this.#routedBySession.get(session.id);
|
|
2269
|
+
if (entry)
|
|
2270
|
+
this.#declareRoutedDemand(entry);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
1324
2273
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
|
|
1325
2274
|
this.#control?.notify({
|
|
1326
2275
|
type: "focus",
|
|
@@ -1330,25 +2279,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1330
2279
|
inputSequence,
|
|
1331
2280
|
focused,
|
|
1332
2281
|
});
|
|
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
2282
|
}, true);
|
|
1349
2283
|
}
|
|
1350
2284
|
async copySelection(sessionId, viewId, selection, selectAll = false) {
|
|
1351
2285
|
await this.connect();
|
|
2286
|
+
if (this.#routedHost)
|
|
2287
|
+
return "";
|
|
1352
2288
|
const view = this.#views.get(viewId);
|
|
1353
2289
|
if (!view || view.sessionId !== sessionId)
|
|
1354
2290
|
return "";
|
|
@@ -1383,6 +2319,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1383
2319
|
return response.text;
|
|
1384
2320
|
}
|
|
1385
2321
|
interrupt(sessionId, viewId) {
|
|
2322
|
+
if (this.#routedHost) {
|
|
2323
|
+
this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
1386
2326
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "interrupt", sessionId, viewId, attachmentEpoch, inputSequence }));
|
|
1387
2327
|
const handle = this.#handleBySessionId.get(sessionId);
|
|
1388
2328
|
if (handle)
|
|
@@ -1443,9 +2383,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1443
2383
|
this.#postWorker({ type: "drop-session", sessionHandle: handle });
|
|
1444
2384
|
}
|
|
1445
2385
|
unregisterSession(sessionId) {
|
|
2386
|
+
const routed = this.#routedBySession.get(sessionId);
|
|
2387
|
+
if (routed) {
|
|
2388
|
+
for (const viewId of [...routed.viewIds])
|
|
2389
|
+
this.#releaseRoutedView(sessionId, viewId);
|
|
2390
|
+
}
|
|
1446
2391
|
this.#removeRegisteredSession(sessionId, true);
|
|
1447
2392
|
}
|
|
1448
2393
|
terminate(sessionId, source = "user") {
|
|
2394
|
+
if (this.#routedHost) {
|
|
2395
|
+
void this.#routedHost.terminate?.(sessionId, source);
|
|
2396
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
2397
|
+
if (entry) {
|
|
2398
|
+
for (const viewId of [...entry.viewIds])
|
|
2399
|
+
this.#releaseRoutedView(sessionId, viewId);
|
|
2400
|
+
}
|
|
2401
|
+
this.#removeRegisteredSession(sessionId, false);
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
1449
2404
|
this.#control?.notify({ type: "terminate", sessionId, source });
|
|
1450
2405
|
this.#removeRegisteredSession(sessionId, false);
|
|
1451
2406
|
}
|
|
@@ -1455,6 +2410,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1455
2410
|
return;
|
|
1456
2411
|
view.desiredCols = cols;
|
|
1457
2412
|
view.desiredRows = rows;
|
|
2413
|
+
if (!view.resizeControlRequested)
|
|
2414
|
+
return;
|
|
2415
|
+
if (this.#routedHost) {
|
|
2416
|
+
const entry = this.#routedBySession.get(sessionId);
|
|
2417
|
+
if (entry)
|
|
2418
|
+
this.#claimRoutedGeometry(entry, viewId, cols, rows);
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
1458
2421
|
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
|
|
1459
2422
|
// Dimensions are one of the funnel's conditions: a pane that measured
|
|
1460
2423
|
// itself while uncontrolled may now be able to take control.
|
|
@@ -1495,6 +2458,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1495
2458
|
request.reject(new Error("Terminal runtime was disposed during a performance request"));
|
|
1496
2459
|
}
|
|
1497
2460
|
this.#performanceRequests.clear();
|
|
2461
|
+
for (const request of this.#counterRequests.values()) {
|
|
2462
|
+
window.clearTimeout(request.timer);
|
|
2463
|
+
request.reject(new Error("Terminal runtime was disposed during a counter request"));
|
|
2464
|
+
}
|
|
2465
|
+
this.#counterRequests.clear();
|
|
1498
2466
|
this.#views.clear();
|
|
1499
2467
|
this.#remoteSessions.clear();
|
|
1500
2468
|
this.#controlBySession.clear();
|
|
@@ -1515,15 +2483,28 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1515
2483
|
}
|
|
1516
2484
|
this.#control?.dispose();
|
|
1517
2485
|
this.#control = undefined;
|
|
2486
|
+
this.#routedControl?.dispose();
|
|
2487
|
+
for (const entry of this.#routedBySession.values()) {
|
|
2488
|
+
if (entry.attachTimer !== undefined)
|
|
2489
|
+
window.clearTimeout(entry.attachTimer);
|
|
2490
|
+
if (entry.renewalTimer !== undefined)
|
|
2491
|
+
window.clearTimeout(entry.renewalTimer);
|
|
2492
|
+
}
|
|
2493
|
+
this.#routedBySession.clear();
|
|
2494
|
+
this.#routedByActivation.clear();
|
|
2495
|
+
this.#routedGeometry.clear();
|
|
2496
|
+
this.#routedAttachDeadlineByCell.clear();
|
|
1518
2497
|
this.#serverProtocolMinor = 0;
|
|
1519
2498
|
this.#worker.terminate();
|
|
1520
|
-
|
|
1521
|
-
ports.
|
|
1522
|
-
|
|
1523
|
-
|
|
2499
|
+
if (this.#ports) {
|
|
2500
|
+
void this.#ports.then((ports) => {
|
|
2501
|
+
ports.control.close();
|
|
2502
|
+
ports.frames.close();
|
|
2503
|
+
}, () => undefined);
|
|
2504
|
+
}
|
|
1524
2505
|
}
|
|
1525
2506
|
#sendResize(viewId, view, cols, rows) {
|
|
1526
|
-
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
2507
|
+
if (!view.resizeControlRequested || view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
1527
2508
|
return;
|
|
1528
2509
|
view.resizeSequence += 1;
|
|
1529
2510
|
this.#control?.notify({
|