@vibecook/ghosttea-react 0.11.1 → 0.13.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 +86 -4
- package/dist/TerminalSurface.d.ts +53 -0
- package/dist/TerminalSurface.d.ts.map +1 -1
- package/dist/TerminalSurface.js +106 -12
- package/dist/TerminalSurface.js.map +1 -1
- package/dist/appearance/AppearanceSettings.d.ts +5 -2
- package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
- package/dist/appearance/AppearanceSettings.js +14 -3
- package/dist/appearance/AppearanceSettings.js.map +1 -1
- package/dist/appearance/BackdropBlurControl.d.ts +5 -0
- package/dist/appearance/BackdropBlurControl.d.ts.map +1 -0
- package/dist/appearance/BackdropBlurControl.js +42 -0
- package/dist/appearance/BackdropBlurControl.js.map +1 -0
- package/dist/appearance/types.d.ts +10 -0
- package/dist/appearance/types.d.ts.map +1 -1
- package/dist/catalog-budget.d.ts +2 -2
- package/dist/catalog-budget.d.ts.map +1 -1
- package/dist/catalog-budget.js +2 -2
- package/dist/catalog-budget.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/link-input.d.ts +18 -0
- package/dist/link-input.d.ts.map +1 -0
- package/dist/link-input.js +27 -0
- package/dist/link-input.js.map +1 -0
- package/dist/renderers/background-geometry.d.ts +16 -0
- package/dist/renderers/background-geometry.d.ts.map +1 -0
- package/dist/renderers/background-geometry.js +20 -0
- package/dist/renderers/background-geometry.js.map +1 -0
- package/dist/renderers/glyph-atlas.d.ts +43 -0
- package/dist/renderers/glyph-atlas.d.ts.map +1 -0
- package/dist/renderers/glyph-atlas.js +175 -0
- package/dist/renderers/glyph-atlas.js.map +1 -0
- package/dist/renderers/light-adaptation.d.ts +36 -0
- package/dist/renderers/light-adaptation.d.ts.map +1 -0
- package/dist/renderers/light-adaptation.js +332 -0
- package/dist/renderers/light-adaptation.js.map +1 -0
- package/dist/renderers/types.d.ts +23 -0
- package/dist/renderers/types.d.ts.map +1 -1
- package/dist/renderers/types.js.map +1 -1
- package/dist/renderers/webgpu-renderer.d.ts +4 -0
- package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
- package/dist/renderers/webgpu-renderer.js +331 -239
- package/dist/renderers/webgpu-renderer.js.map +1 -1
- package/dist/routed-control.d.ts +26 -2
- package/dist/routed-control.d.ts.map +1 -1
- package/dist/routed-control.js +145 -27
- package/dist/routed-control.js.map +1 -1
- package/dist/routed-frames.d.ts +1 -1
- package/dist/routed-frames.d.ts.map +1 -1
- package/dist/routed-frames.js +3 -7
- package/dist/routed-frames.js.map +1 -1
- package/dist/row-damage.d.ts +21 -0
- package/dist/row-damage.d.ts.map +1 -0
- package/dist/row-damage.js +76 -0
- package/dist/row-damage.js.map +1 -0
- package/dist/runtime.d.ts +97 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +489 -49
- package/dist/runtime.js.map +1 -1
- package/dist/styles.css +24 -0
- package/dist/terminal-render.worker.js +979 -297
- package/dist/terminal-render.worker.js.map +4 -4
- package/dist/use-terminal-links.d.ts +17 -0
- package/dist/use-terminal-links.d.ts.map +1 -0
- package/dist/use-terminal-links.js +101 -0
- package/dist/use-terminal-links.js.map +1 -0
- package/dist/worker-messages.d.ts +27 -1
- package/dist/worker-messages.d.ts.map +1 -1
- package/dist/workspace/Workspace.d.ts +19 -3
- package/dist/workspace/Workspace.d.ts.map +1 -1
- package/dist/workspace/Workspace.js +53 -20
- package/dist/workspace/Workspace.js.map +1 -1
- package/dist/workspace/index.d.ts +2 -2
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/pane-focus.d.ts +8 -0
- package/dist/workspace/pane-focus.d.ts.map +1 -0
- package/dist/workspace/pane-focus.js +24 -0
- package/dist/workspace/pane-focus.js.map +1 -0
- package/dist/workspace/pane-layout.d.ts +5 -2
- package/dist/workspace/pane-layout.d.ts.map +1 -1
- package/dist/workspace/pane-layout.js +45 -6
- package/dist/workspace/pane-layout.js.map +1 -1
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { terminalLinkUrl } from "@vibecook/ghosttea-protocol";
|
|
1
2
|
import { ControlClient } from "@vibecook/ghosttea";
|
|
2
3
|
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
4
|
import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame";
|
|
@@ -24,6 +25,57 @@ export function showsStaleScreen(state) {
|
|
|
24
25
|
export function sessionIsFrozen(state) {
|
|
25
26
|
return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
|
|
26
27
|
}
|
|
28
|
+
function geometryHolderRecord(holder) {
|
|
29
|
+
return { clientId: holder.clientId, viewId: holder.viewId, holderGeneration: holder.holderGeneration };
|
|
30
|
+
}
|
|
31
|
+
function sameGeometryHolder(a, b) {
|
|
32
|
+
if (a === undefined || b === undefined)
|
|
33
|
+
return a === b;
|
|
34
|
+
return a.clientId === b.clientId && a.viewId === b.viewId && a.holderGeneration === b.holderGeneration;
|
|
35
|
+
}
|
|
36
|
+
/** A take's context on the control leg: which view and take it is, and the seat state it was built on. */
|
|
37
|
+
class RoutedGeometryTake {
|
|
38
|
+
viewId;
|
|
39
|
+
/** The view's request serial this take was sent under. */
|
|
40
|
+
viewSerial;
|
|
41
|
+
/** The session's take serial. */
|
|
42
|
+
serial;
|
|
43
|
+
attempt;
|
|
44
|
+
basisHolder;
|
|
45
|
+
basisRevision;
|
|
46
|
+
constructor(viewId, viewSerial, serial, attempt, basis) {
|
|
47
|
+
this.viewId = viewId;
|
|
48
|
+
this.viewSerial = viewSerial;
|
|
49
|
+
this.serial = serial;
|
|
50
|
+
this.attempt = attempt;
|
|
51
|
+
this.basisHolder = basis?.holder;
|
|
52
|
+
this.basisRevision = basis?.revision ?? 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A plain claim's context: the view that sent it, under which serial, and whether it re-sends a grid that did not land. */
|
|
56
|
+
class RoutedGeometryClaim {
|
|
57
|
+
viewId;
|
|
58
|
+
viewSerial;
|
|
59
|
+
trailing;
|
|
60
|
+
constructor(viewId, viewSerial, trailing) {
|
|
61
|
+
this.viewId = viewId;
|
|
62
|
+
this.viewSerial = viewSerial;
|
|
63
|
+
this.trailing = trailing;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Refusals that describe the seat as it is, so a take built on older news can retry against them. */
|
|
67
|
+
const RETRYABLE_TAKE_REFUSALS = new Set(["SEAT_HELD", "STALE_REVISION", "NOT_HOLDER"]);
|
|
68
|
+
/**
|
|
69
|
+
* Whether a take's refusal names a seat other than the one the take was built
|
|
70
|
+
* on. A refusal that repeats the take's own view of the seat (a cell that does
|
|
71
|
+
* not allow the take at all) would only be refused again.
|
|
72
|
+
*/
|
|
73
|
+
function takeRefusalNamesNews(refused, take) {
|
|
74
|
+
if (!RETRYABLE_TAKE_REFUSALS.has(refused.code) || refused.geometryRevision === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
const holder = refused.currentHolder === undefined ? undefined : geometryHolderRecord(refused.currentHolder);
|
|
77
|
+
return refused.geometryRevision !== take.basisRevision || !sameGeometryHolder(holder, take.basisHolder);
|
|
78
|
+
}
|
|
27
79
|
const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
|
|
28
80
|
const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
|
|
29
81
|
const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
|
|
@@ -52,6 +104,9 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
|
|
|
52
104
|
}
|
|
53
105
|
export class GhostteaTerminalRuntime extends EventTarget {
|
|
54
106
|
#worker;
|
|
107
|
+
#sessionsCreatedHere = new Set();
|
|
108
|
+
#sessionsWithInput = new Set();
|
|
109
|
+
#sessionsDrawn = new Set();
|
|
55
110
|
#ports;
|
|
56
111
|
#routedHost;
|
|
57
112
|
#routedReceiverCapacities;
|
|
@@ -110,6 +165,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
110
165
|
#performanceRequestId = 1;
|
|
111
166
|
#performanceRequests = new Map();
|
|
112
167
|
#counterRequests = new Map();
|
|
168
|
+
#linksByHandle = new Map();
|
|
113
169
|
#disposed = false;
|
|
114
170
|
constructor(options) {
|
|
115
171
|
super();
|
|
@@ -155,6 +211,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
155
211
|
else if (data.type === "clipboard-write") {
|
|
156
212
|
this.#platform.writeClipboard(data.text);
|
|
157
213
|
}
|
|
214
|
+
else if (data.type === "link-targets") {
|
|
215
|
+
this.#linksByHandle.set(data.sessionHandle, data.links.filter((link) => terminalLinkUrl(link.uri) !== null));
|
|
216
|
+
this.dispatchEvent(new CustomEvent("link-targets", { detail: { sessionHandle: data.sessionHandle } }));
|
|
217
|
+
}
|
|
158
218
|
else if (data.type === "scrollbar-state") {
|
|
159
219
|
this.#scrollbarByHandle.set(data.sessionHandle, data.scrollbar);
|
|
160
220
|
this.dispatchEvent(new CustomEvent("scrollbar-state", {
|
|
@@ -172,6 +232,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
172
232
|
this.#scheduleMetadataRefresh(data.sessionHandle);
|
|
173
233
|
this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
|
|
174
234
|
}
|
|
235
|
+
else if (data.type === "session-drawn") {
|
|
236
|
+
this.#recordSessionDrawn(data.sessionHandle);
|
|
237
|
+
}
|
|
175
238
|
else if (data.type === "catalog-pressure") {
|
|
176
239
|
console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
|
|
177
240
|
this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
|
|
@@ -196,6 +259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
196
259
|
else if (data.type === "routed-frames-event") {
|
|
197
260
|
this.#handleRoutedFramesEvent(data.event);
|
|
198
261
|
}
|
|
262
|
+
else if (data.type === "terminal-modes") {
|
|
263
|
+
this.#noteMouseTracking(data.sessionHandle, data.mouseTracking);
|
|
264
|
+
}
|
|
199
265
|
else if (data.type === "renderer-reload-required") {
|
|
200
266
|
console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
|
|
201
267
|
this.#platform.setForceCanvasFallback(true);
|
|
@@ -212,6 +278,26 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
212
278
|
return;
|
|
213
279
|
this.#worker.postMessage(message, transfer);
|
|
214
280
|
}
|
|
281
|
+
/** Whether this renderer has attempted terminal input during this session's lifetime.
|
|
282
|
+
* Restored or externally created sessions must never be treated as unused from this alone. */
|
|
283
|
+
sessionHasInput(sessionId) {
|
|
284
|
+
return this.#sessionsWithInput.has(sessionId);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a committed frame of this session has put something on screen in
|
|
288
|
+
* this renderer: what a host that just started a program waits for before it
|
|
289
|
+
* reveals the terminal. Once true it stays true while the session is
|
|
290
|
+
* registered here (a restart in place is the same session and does not draw
|
|
291
|
+
* "first" again); a `session-drawn` event (`detail: { sessionId }`) announces
|
|
292
|
+
* the change, and a session registered again announces again.
|
|
293
|
+
*/
|
|
294
|
+
sessionHasDrawn(sessionId) {
|
|
295
|
+
return this.#sessionsDrawn.has(sessionId);
|
|
296
|
+
}
|
|
297
|
+
/** A local birth receipt, never inferred from registration or an empty input history. */
|
|
298
|
+
sessionCreatedHere(sessionId) {
|
|
299
|
+
return this.#sessionsCreatedHere.has(sessionId);
|
|
300
|
+
}
|
|
215
301
|
get rendererBackend() {
|
|
216
302
|
return this.#rendererBackend;
|
|
217
303
|
}
|
|
@@ -225,6 +311,48 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
225
311
|
return false;
|
|
226
312
|
return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
|
|
227
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The routed session's geometry seat as this runtime last saw it, or
|
|
316
|
+
* `undefined` before anything was seen (and always on the port-pair
|
|
317
|
+
* transport). A change of holder also fires `geometry-holder-changed`; a
|
|
318
|
+
* resize by the same holder only moves `revision`, without an event.
|
|
319
|
+
*/
|
|
320
|
+
geometryHolder(sessionHandle) {
|
|
321
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
322
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
323
|
+
const geometry = entry ? this.#routedGeometry.get(entry.sessionId) : undefined;
|
|
324
|
+
return entry && geometry ? this.#geometryHolderState(entry, geometry) : undefined;
|
|
325
|
+
}
|
|
326
|
+
#geometryHolderState(entry, geometry) {
|
|
327
|
+
const holder = geometry.holder;
|
|
328
|
+
return {
|
|
329
|
+
holder: holder === undefined ? null : { ...holder },
|
|
330
|
+
revision: geometry.revision,
|
|
331
|
+
local: holder !== undefined && holder.clientId === entry.clientId,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Whether this view, under this runtime's client, holds the seat as last seen. */
|
|
335
|
+
#routedViewHolds(entry, geometry, viewId) {
|
|
336
|
+
const holder = geometry?.holder;
|
|
337
|
+
return holder !== undefined && holder.viewId === viewId && holder.clientId === entry.clientId;
|
|
338
|
+
}
|
|
339
|
+
/** Every write of a session's seat state goes through here, so a holder change always fires its event. */
|
|
340
|
+
#setRoutedGeometry(entry, next) {
|
|
341
|
+
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
342
|
+
if (next === undefined)
|
|
343
|
+
this.#routedGeometry.delete(entry.sessionId);
|
|
344
|
+
else
|
|
345
|
+
this.#routedGeometry.set(entry.sessionId, next);
|
|
346
|
+
if ((previous === undefined) === (next === undefined) && sameGeometryHolder(previous?.holder, next?.holder))
|
|
347
|
+
return;
|
|
348
|
+
const detail = {
|
|
349
|
+
sessionId: entry.sessionId,
|
|
350
|
+
sessionHandle: entry.sessionHandle,
|
|
351
|
+
previous: previous === undefined ? undefined : this.#geometryHolderState(entry, previous),
|
|
352
|
+
current: next === undefined ? undefined : this.#geometryHolderState(entry, next),
|
|
353
|
+
};
|
|
354
|
+
this.dispatchEvent(new CustomEvent("geometry-holder-changed", { detail }));
|
|
355
|
+
}
|
|
228
356
|
#resolvePerformanceRequest(requestId, value) {
|
|
229
357
|
const pending = this.#performanceRequests.get(requestId);
|
|
230
358
|
if (!pending)
|
|
@@ -352,11 +480,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
352
480
|
this.#returnFrameCredit(data.byteLength);
|
|
353
481
|
return;
|
|
354
482
|
}
|
|
355
|
-
|
|
356
|
-
if (this.#mouseTrackingByHandle.get(sessionHandle) !== tracking) {
|
|
357
|
-
this.#mouseTrackingByHandle.set(sessionHandle, tracking);
|
|
358
|
-
this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
|
|
359
|
-
}
|
|
483
|
+
this.#noteMouseTracking(sessionHandle, (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0);
|
|
360
484
|
this.#scheduleMetadataRefresh(sessionHandle);
|
|
361
485
|
this.#postWorker({ type: "frame", packet: data }, [data]);
|
|
362
486
|
};
|
|
@@ -804,6 +928,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
804
928
|
if (!this.#routedHost.createSession)
|
|
805
929
|
throw new Error("The routed host does not provide session creation");
|
|
806
930
|
const session = await this.#routedHost.createSession(options);
|
|
931
|
+
this.#sessionsCreatedHere.add(session.id);
|
|
807
932
|
this.registerSession(session);
|
|
808
933
|
return session;
|
|
809
934
|
}
|
|
@@ -821,6 +946,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
821
946
|
});
|
|
822
947
|
if (response.type !== "session-created")
|
|
823
948
|
throw new Error("ghosttead returned an unexpected response");
|
|
949
|
+
this.#sessionsCreatedHere.add(response.session.id);
|
|
824
950
|
this.registerSession(response.session);
|
|
825
951
|
console.info(`[terminal-runtime] created session ${response.session.id}`);
|
|
826
952
|
return response.session;
|
|
@@ -977,11 +1103,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
977
1103
|
if (response.type !== "ok")
|
|
978
1104
|
throw new Error("ghosttead rejected frame resynchronization");
|
|
979
1105
|
}
|
|
980
|
-
mount(sessionId, sessionHandle, viewId, canvas) {
|
|
1106
|
+
mount(sessionId, sessionHandle, viewId, canvas, initialSize) {
|
|
981
1107
|
if (this.#disposed)
|
|
982
1108
|
throw new Error("Cannot mount a disposed terminal runtime");
|
|
983
1109
|
if (this.#routedHost)
|
|
984
|
-
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
|
|
1110
|
+
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize);
|
|
985
1111
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
986
1112
|
if (mounted) {
|
|
987
1113
|
if (!mounted.active)
|
|
@@ -996,11 +1122,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
996
1122
|
}
|
|
997
1123
|
return this.#createMountLease(mounted);
|
|
998
1124
|
}
|
|
1125
|
+
const size = initialSize ?? {
|
|
1126
|
+
width: canvas.clientWidth || 0,
|
|
1127
|
+
height: canvas.clientHeight || 0,
|
|
1128
|
+
dpr: window.devicePixelRatio || 1,
|
|
1129
|
+
};
|
|
1130
|
+
// Allocate the first render target at its actual size, too: the backend
|
|
1131
|
+
// must not build a default 300x150 scene texture and immediately replace it.
|
|
1132
|
+
canvas.width = Math.max(1, Math.round(size.width * size.dpr));
|
|
1133
|
+
canvas.height = Math.max(1, Math.round(size.height * size.dpr));
|
|
999
1134
|
const offscreen = canvas.transferControlToOffscreen();
|
|
1000
1135
|
const subscription = this.#retainFrameSubscription(sessionHandle);
|
|
1001
1136
|
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
1002
1137
|
this.#mountGenerationBySurface.set(viewId, generation);
|
|
1003
|
-
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
1138
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
|
|
1004
1139
|
const entry = {
|
|
1005
1140
|
canvas,
|
|
1006
1141
|
sessionHandle,
|
|
@@ -1029,6 +1164,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1029
1164
|
lastAttachmentEpoch: undefined,
|
|
1030
1165
|
claimedEpoch: undefined,
|
|
1031
1166
|
claimedRevision: 0,
|
|
1167
|
+
routedGeometrySerial: 0,
|
|
1032
1168
|
};
|
|
1033
1169
|
this.#views.set(viewId, view);
|
|
1034
1170
|
const remote = this.#remoteSessions.get(sessionId);
|
|
@@ -1087,7 +1223,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1087
1223
|
.catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
|
|
1088
1224
|
return this.#createMountLease(entry);
|
|
1089
1225
|
}
|
|
1090
|
-
#mountRouted(sessionId, sessionHandle, viewId, canvas) {
|
|
1226
|
+
#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize) {
|
|
1091
1227
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
1092
1228
|
if (mounted) {
|
|
1093
1229
|
if (!mounted.active)
|
|
@@ -1102,10 +1238,19 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1102
1238
|
}
|
|
1103
1239
|
return this.#createMountLease(mounted);
|
|
1104
1240
|
}
|
|
1241
|
+
const size = initialSize ?? {
|
|
1242
|
+
width: canvas.clientWidth || 0,
|
|
1243
|
+
height: canvas.clientHeight || 0,
|
|
1244
|
+
dpr: window.devicePixelRatio || 1,
|
|
1245
|
+
};
|
|
1246
|
+
// Allocate the first render target at its actual size, too: the backend
|
|
1247
|
+
// must not build a default 300x150 scene texture and immediately replace it.
|
|
1248
|
+
canvas.width = Math.max(1, Math.round(size.width * size.dpr));
|
|
1249
|
+
canvas.height = Math.max(1, Math.round(size.height * size.dpr));
|
|
1105
1250
|
const offscreen = canvas.transferControlToOffscreen();
|
|
1106
1251
|
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
1107
1252
|
this.#mountGenerationBySurface.set(viewId, generation);
|
|
1108
|
-
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
1253
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
|
|
1109
1254
|
const entry = {
|
|
1110
1255
|
canvas,
|
|
1111
1256
|
sessionHandle,
|
|
@@ -1136,6 +1281,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1136
1281
|
lastAttachmentEpoch: undefined,
|
|
1137
1282
|
claimedEpoch: undefined,
|
|
1138
1283
|
claimedRevision: 0,
|
|
1284
|
+
routedGeometrySerial: 0,
|
|
1139
1285
|
});
|
|
1140
1286
|
const activation = this.#routedBySession.get(sessionId);
|
|
1141
1287
|
if (activation)
|
|
@@ -1172,6 +1318,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1172
1318
|
sessionHandle,
|
|
1173
1319
|
state: initialRoutedActivation(sessionId, activationId, inputPolicy),
|
|
1174
1320
|
viewIds: new Set([viewId]),
|
|
1321
|
+
takeSerial: 0,
|
|
1175
1322
|
recoveryAttempts: 0,
|
|
1176
1323
|
preAuthRemints: { control: 0, frames: 0 },
|
|
1177
1324
|
protocolFailures: { control: 0, frames: 0 },
|
|
@@ -1217,15 +1364,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1217
1364
|
const host = this.#routedHost;
|
|
1218
1365
|
if (!host || this.#disposed || entry.viewIds.size === 0)
|
|
1219
1366
|
return;
|
|
1367
|
+
const activationId = entry.state.activationId;
|
|
1368
|
+
const isCurrent = () => !this.#disposed &&
|
|
1369
|
+
entry.viewIds.size > 0 &&
|
|
1370
|
+
entry.state.activationId === activationId &&
|
|
1371
|
+
this.#routedByActivation.get(activationId) === entry &&
|
|
1372
|
+
this.#routedBySession.get(entry.sessionId) === entry;
|
|
1220
1373
|
let ticket;
|
|
1221
1374
|
try {
|
|
1222
1375
|
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1223
1376
|
}
|
|
1224
1377
|
catch (error) {
|
|
1225
|
-
|
|
1378
|
+
if (isCurrent())
|
|
1379
|
+
this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
|
|
1226
1380
|
return;
|
|
1227
1381
|
}
|
|
1228
|
-
if (
|
|
1382
|
+
if (!isCurrent())
|
|
1229
1383
|
return;
|
|
1230
1384
|
if (!this.#routedTicketMatches(entry, ticket)) {
|
|
1231
1385
|
this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
|
|
@@ -1235,6 +1389,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1235
1389
|
if (!ticket.endpoints)
|
|
1236
1390
|
return;
|
|
1237
1391
|
entry.ticket = ticket;
|
|
1392
|
+
entry.clientId = ticket.attachGrant.claims.clientId;
|
|
1238
1393
|
this.#transitionRouted(entry, { type: "transport-ready" });
|
|
1239
1394
|
this.#routedControl.attach({
|
|
1240
1395
|
cellBootId: ticket.route.cellBootId,
|
|
@@ -1242,10 +1397,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1242
1397
|
transportGrant: ticket.transportGrant,
|
|
1243
1398
|
attachGrant: ticket.attachGrant,
|
|
1244
1399
|
activationId: entry.state.activationId,
|
|
1400
|
+
replacePreviousActivation: true,
|
|
1245
1401
|
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1246
1402
|
initialDemand: this.#routedDemand(entry),
|
|
1247
1403
|
capabilities: this.#routedCapabilities,
|
|
1248
1404
|
});
|
|
1405
|
+
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1406
|
+
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1407
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1408
|
+
}
|
|
1409
|
+
#startRoutedFrames(entry) {
|
|
1410
|
+
const ticket = entry.ticket;
|
|
1411
|
+
if (!ticket?.endpoints || !entry.state.controlAttached || entry.framesAttachStarted || entry.viewIds.size === 0)
|
|
1412
|
+
return;
|
|
1413
|
+
// Control creates the native activation. The frames socket may already be
|
|
1414
|
+
// warm; sending on both legs at once races that creation on every remount.
|
|
1415
|
+
entry.framesAttachStarted = true;
|
|
1249
1416
|
this.#postWorker({
|
|
1250
1417
|
type: "routed-frames-attach",
|
|
1251
1418
|
request: {
|
|
@@ -1260,19 +1427,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1260
1427
|
capabilities: this.#routedCapabilities,
|
|
1261
1428
|
},
|
|
1262
1429
|
});
|
|
1263
|
-
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1264
|
-
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1265
|
-
this.#scheduleRoutedRenewal(entry);
|
|
1266
1430
|
}
|
|
1267
1431
|
#stopRoutedActivation(entry) {
|
|
1268
1432
|
this.#routedControl?.detach(entry.state.activationId);
|
|
1269
1433
|
this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
|
|
1434
|
+
delete entry.framesAttachStarted;
|
|
1270
1435
|
if (entry.attachTimer !== undefined)
|
|
1271
1436
|
window.clearTimeout(entry.attachTimer);
|
|
1272
1437
|
delete entry.attachTimer;
|
|
1273
1438
|
if (entry.renewalTimer !== undefined)
|
|
1274
1439
|
window.clearTimeout(entry.renewalTimer);
|
|
1275
1440
|
delete entry.renewalTimer;
|
|
1441
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1442
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1443
|
+
delete entry.cellLeaseTimer;
|
|
1444
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1445
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1446
|
+
delete entry.workerLeaseTimer;
|
|
1276
1447
|
delete entry.ticket;
|
|
1277
1448
|
}
|
|
1278
1449
|
#transitionRouted(entry, event) {
|
|
@@ -1346,23 +1517,36 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1346
1517
|
grantGeneration: event.attached.grantGenerationAccepted,
|
|
1347
1518
|
rights: event.attached.rights,
|
|
1348
1519
|
});
|
|
1520
|
+
this.#startRoutedFrames(entry);
|
|
1349
1521
|
const previousGeometry = this.#routedGeometry.get(entry.sessionId);
|
|
1522
|
+
const previousHolder = previousGeometry?.holder;
|
|
1350
1523
|
if (!event.attached.rights.includes("geometry") &&
|
|
1351
|
-
previousGeometry
|
|
1352
|
-
|
|
1524
|
+
previousGeometry !== undefined &&
|
|
1525
|
+
previousHolder !== undefined &&
|
|
1526
|
+
this.#routedViewHolds(entry, previousGeometry, previousHolder.viewId) &&
|
|
1527
|
+
entry.viewIds.has(previousHolder.viewId)) {
|
|
1353
1528
|
// The cell auto-releases a holder when renewal drops the geometry
|
|
1354
1529
|
// right. Preserve its next CAS revision without retaining authority.
|
|
1355
|
-
this.#
|
|
1530
|
+
this.#setRoutedGeometry(entry, {
|
|
1356
1531
|
revision: previousGeometry.revision + 1,
|
|
1357
1532
|
...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
|
|
1358
1533
|
...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
|
|
1359
1534
|
});
|
|
1360
1535
|
}
|
|
1536
|
+
// A take asked before the leg could carry it goes out now. It decides
|
|
1537
|
+
// the seat, so the plain claims below would only draw refusals.
|
|
1538
|
+
const queuedTake = entry.queuedTake;
|
|
1539
|
+
delete entry.queuedTake;
|
|
1540
|
+
if (queuedTake !== undefined &&
|
|
1541
|
+
queuedTake.serial === entry.takeSerial &&
|
|
1542
|
+
this.#sendRoutedTake(entry, queuedTake.viewId, queuedTake.serial, 0)) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1361
1545
|
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
1362
1546
|
for (const viewId of entry.viewIds) {
|
|
1363
1547
|
const view = this.#views.get(viewId);
|
|
1364
1548
|
if (view?.resizeControlRequested &&
|
|
1365
|
-
geometry
|
|
1549
|
+
!this.#routedViewHolds(entry, geometry, viewId) &&
|
|
1366
1550
|
view.desiredCols !== undefined &&
|
|
1367
1551
|
view.desiredRows !== undefined) {
|
|
1368
1552
|
this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
|
|
@@ -1384,9 +1568,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1384
1568
|
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1385
1569
|
if (!entry || event.status.sessionId !== entry.sessionId)
|
|
1386
1570
|
return;
|
|
1571
|
+
const previousSequence = entry.state.lastCellStatusSequence;
|
|
1387
1572
|
this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
|
|
1388
1573
|
const sequence = entry.state.lastCellStatusSequence;
|
|
1389
|
-
|
|
1574
|
+
if (sequence === previousSequence)
|
|
1575
|
+
return;
|
|
1576
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1577
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1578
|
+
entry.cellLeaseTimer = window.setTimeout(() => {
|
|
1579
|
+
delete entry.cellLeaseTimer;
|
|
1390
1580
|
if (entry.state.lastCellStatusSequence !== sequence)
|
|
1391
1581
|
return;
|
|
1392
1582
|
this.#transitionRouted(entry, { type: "cell-lease-expired" });
|
|
@@ -1401,14 +1591,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1401
1591
|
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1402
1592
|
if (!entry)
|
|
1403
1593
|
return;
|
|
1404
|
-
this.#
|
|
1405
|
-
|
|
1406
|
-
holderGeneration: event.committed.holder.holderGeneration,
|
|
1594
|
+
this.#setRoutedGeometry(entry, {
|
|
1595
|
+
holder: geometryHolderRecord(event.committed.holder),
|
|
1407
1596
|
revision: event.committed.geometryRevision,
|
|
1408
1597
|
cols: event.committed.cols,
|
|
1409
1598
|
rows: event.committed.rows,
|
|
1410
1599
|
});
|
|
1411
|
-
this
|
|
1600
|
+
this.#trailRoutedResize(entry, event.request?.context, undefined);
|
|
1601
|
+
this.dispatchEvent(new CustomEvent("routed-geometry", {
|
|
1602
|
+
detail: {
|
|
1603
|
+
sessionId: entry.sessionId,
|
|
1604
|
+
type: event.type,
|
|
1605
|
+
...(event.activationId === undefined ? {} : { activationId: event.activationId }),
|
|
1606
|
+
committed: event.committed,
|
|
1607
|
+
},
|
|
1608
|
+
}));
|
|
1412
1609
|
return;
|
|
1413
1610
|
}
|
|
1414
1611
|
if (event.type === "geometry-refused") {
|
|
@@ -1416,14 +1613,32 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1416
1613
|
if (entry && event.refused.geometryRevision !== undefined) {
|
|
1417
1614
|
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
1418
1615
|
const holder = event.refused.currentHolder;
|
|
1419
|
-
this.#
|
|
1616
|
+
this.#setRoutedGeometry(entry, {
|
|
1420
1617
|
revision: event.refused.geometryRevision,
|
|
1421
|
-
...(holder === undefined ? {} : {
|
|
1618
|
+
...(holder === undefined ? {} : { holder: geometryHolderRecord(holder) }),
|
|
1422
1619
|
...(previous?.cols === undefined ? {} : { cols: previous.cols }),
|
|
1423
1620
|
...(previous?.rows === undefined ? {} : { rows: previous.rows }),
|
|
1424
1621
|
});
|
|
1425
1622
|
}
|
|
1426
|
-
|
|
1623
|
+
// A take refused on stale news retries once against the seat the
|
|
1624
|
+
// refusal names; only the session's latest take does.
|
|
1625
|
+
const take = event.request?.context instanceof RoutedGeometryTake ? event.request.context : undefined;
|
|
1626
|
+
if (entry &&
|
|
1627
|
+
take &&
|
|
1628
|
+
take.attempt === 0 &&
|
|
1629
|
+
take.serial === entry.takeSerial &&
|
|
1630
|
+
takeRefusalNamesNews(event.refused, take)) {
|
|
1631
|
+
this.#sendRoutedTake(entry, take.viewId, take.serial, 1);
|
|
1632
|
+
}
|
|
1633
|
+
if (entry)
|
|
1634
|
+
this.#trailRoutedResize(entry, event.request?.context, event.refused.code);
|
|
1635
|
+
this.dispatchEvent(new CustomEvent("routed-geometry-refused", {
|
|
1636
|
+
detail: {
|
|
1637
|
+
type: event.type,
|
|
1638
|
+
...(event.activationId === undefined ? {} : { activationId: event.activationId }),
|
|
1639
|
+
refused: event.refused,
|
|
1640
|
+
},
|
|
1641
|
+
}));
|
|
1427
1642
|
return;
|
|
1428
1643
|
}
|
|
1429
1644
|
if (event.type === "transport-closed") {
|
|
@@ -1522,9 +1737,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1522
1737
|
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1523
1738
|
if (!entry)
|
|
1524
1739
|
return;
|
|
1740
|
+
const previousSequence = entry.state.lastWorkerStatusSequence;
|
|
1525
1741
|
this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
|
|
1526
1742
|
const sequence = entry.state.lastWorkerStatusSequence;
|
|
1527
|
-
|
|
1743
|
+
if (sequence === previousSequence)
|
|
1744
|
+
return;
|
|
1745
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1746
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1747
|
+
entry.workerLeaseTimer = window.setTimeout(() => {
|
|
1748
|
+
delete entry.workerLeaseTimer;
|
|
1528
1749
|
if (entry.state.lastWorkerStatusSequence !== sequence)
|
|
1529
1750
|
return;
|
|
1530
1751
|
this.#transitionRouted(entry, { type: "worker-lease-expired" });
|
|
@@ -1638,6 +1859,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1638
1859
|
return;
|
|
1639
1860
|
}
|
|
1640
1861
|
entry.ticket = ticket;
|
|
1862
|
+
entry.clientId = ticket.attachGrant.claims.clientId;
|
|
1641
1863
|
this.#routedControl?.renew(activationId, ticket.attachGrant);
|
|
1642
1864
|
this.#scheduleRoutedRenewal(entry);
|
|
1643
1865
|
this.#postWorker({
|
|
@@ -1679,14 +1901,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1679
1901
|
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1680
1902
|
return;
|
|
1681
1903
|
}
|
|
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
1904
|
const previousActivationId = entry.state.activationId;
|
|
1688
1905
|
this.#routedControl?.detach(previousActivationId);
|
|
1689
1906
|
this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
|
|
1907
|
+
delete entry.framesAttachStarted;
|
|
1690
1908
|
this.#routedByActivation.delete(previousActivationId);
|
|
1691
1909
|
const nextActivationId = crypto.randomUUID();
|
|
1692
1910
|
entry.replacesActivationId = previousActivationId;
|
|
@@ -1710,7 +1928,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1710
1928
|
if (entry.renewalTimer !== undefined)
|
|
1711
1929
|
window.clearTimeout(entry.renewalTimer);
|
|
1712
1930
|
delete entry.renewalTimer;
|
|
1931
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1932
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1933
|
+
delete entry.cellLeaseTimer;
|
|
1934
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1935
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1936
|
+
delete entry.workerLeaseTimer;
|
|
1713
1937
|
delete entry.ticket;
|
|
1938
|
+
// Geometry belongs to the cell-side attach/client/view scope. Preserve it
|
|
1939
|
+
// across a same-route leg replacement, but never carry its revision or
|
|
1940
|
+
// holder generation to a newly routed cell. Forgotten only now, once the
|
|
1941
|
+
// old activation is gone, so a listener of the holder event cannot act
|
|
1942
|
+
// on it.
|
|
1943
|
+
if (reason === "route-stale")
|
|
1944
|
+
this.#setRoutedGeometry(entry, undefined);
|
|
1714
1945
|
const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
|
|
1715
1946
|
window.setTimeout(() => {
|
|
1716
1947
|
if (this.#routedByActivation.get(nextActivationId) !== entry)
|
|
@@ -1789,6 +2020,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1789
2020
|
if (!entry)
|
|
1790
2021
|
return;
|
|
1791
2022
|
entry.viewIds.delete(viewId);
|
|
2023
|
+
if (entry.queuedTake?.viewId === viewId)
|
|
2024
|
+
delete entry.queuedTake;
|
|
1792
2025
|
if (entry.viewIds.size > 0) {
|
|
1793
2026
|
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1794
2027
|
[...entry.viewIds].some((candidate) => {
|
|
@@ -1802,7 +2035,30 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1802
2035
|
this.#transitionRouted(entry, { type: "detach" });
|
|
1803
2036
|
this.#routedBySession.delete(sessionId);
|
|
1804
2037
|
this.#routedByActivation.delete(entry.state.activationId);
|
|
1805
|
-
this.#
|
|
2038
|
+
this.#setRoutedGeometry(entry, undefined);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Detach one of our views and let the seat go with it.
|
|
2042
|
+
*
|
|
2043
|
+
* The daemon clears the resize controller when its holder detaches, but a
|
|
2044
|
+
* local session announces control only through the legacy `control-changed`
|
|
2045
|
+
* frame, which cannot say "no controller": the clear never reaches this
|
|
2046
|
+
* client, and a pane that remounts (a split re-parents its surface) would
|
|
2047
|
+
* see its own previous incarnation on the record forever and never claim.
|
|
2048
|
+
* Our own detach is the one clear this client can predict, so the record is
|
|
2049
|
+
* cleared here at the same revision and the session's remaining views get
|
|
2050
|
+
* their one look at the empty seat (§4.2.3).
|
|
2051
|
+
*/
|
|
2052
|
+
#detachView(sessionId, viewId) {
|
|
2053
|
+
this.#control?.notify({ type: "detach-session", sessionId, viewId });
|
|
2054
|
+
const control = this.#controlBySession.get(sessionId);
|
|
2055
|
+
if (!control?.controller || control.controller.viewId !== viewId)
|
|
2056
|
+
return;
|
|
2057
|
+
this.#controlBySession.set(sessionId, { controller: null, revision: control.revision });
|
|
2058
|
+
for (const other of this.#viewIdsForSession(sessionId)) {
|
|
2059
|
+
if (other !== viewId)
|
|
2060
|
+
this.#maybeReclaim(other);
|
|
2061
|
+
}
|
|
1806
2062
|
}
|
|
1807
2063
|
#createMountLease(mounted) {
|
|
1808
2064
|
let disposed = false;
|
|
@@ -1829,7 +2085,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1829
2085
|
if (this.#routedHost)
|
|
1830
2086
|
this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
|
|
1831
2087
|
else
|
|
1832
|
-
this.#
|
|
2088
|
+
this.#detachView(mounted.sessionId, mounted.viewId);
|
|
1833
2089
|
this.#views.delete(mounted.viewId);
|
|
1834
2090
|
this.#focusByView.delete(mounted.viewId);
|
|
1835
2091
|
}
|
|
@@ -2043,6 +2299,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2043
2299
|
* screen: a partial update repaints part of the stale one, which is exactly
|
|
2044
2300
|
* what would thaw a pane too early.
|
|
2045
2301
|
*/
|
|
2302
|
+
#recordSessionDrawn(sessionHandle) {
|
|
2303
|
+
const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
|
|
2304
|
+
if (sessionId === undefined || this.#sessionsDrawn.has(sessionId))
|
|
2305
|
+
return;
|
|
2306
|
+
this.#sessionsDrawn.add(sessionId);
|
|
2307
|
+
this.dispatchEvent(new CustomEvent("session-drawn", { detail: { sessionId } }));
|
|
2308
|
+
}
|
|
2046
2309
|
#recordCommittedFrame(sessionHandle, fullSnapshot) {
|
|
2047
2310
|
if (!fullSnapshot)
|
|
2048
2311
|
return;
|
|
@@ -2183,6 +2446,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2183
2446
|
return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
|
|
2184
2447
|
}
|
|
2185
2448
|
sendText(sessionId, viewId, text) {
|
|
2449
|
+
if (text.length > 0)
|
|
2450
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2186
2451
|
if (this.#routedHost) {
|
|
2187
2452
|
this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
|
|
2188
2453
|
return;
|
|
@@ -2193,6 +2458,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2193
2458
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
2194
2459
|
}
|
|
2195
2460
|
paste(sessionId, viewId, text) {
|
|
2461
|
+
if (text.length > 0)
|
|
2462
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2196
2463
|
if (this.#routedHost) {
|
|
2197
2464
|
this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
|
|
2198
2465
|
return;
|
|
@@ -2203,6 +2470,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2203
2470
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
2204
2471
|
}
|
|
2205
2472
|
sendKey(sessionId, viewId, event) {
|
|
2473
|
+
if (event.type === "down" && !["Shift", "Control", "Alt", "Meta"].includes(event.key))
|
|
2474
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2206
2475
|
if (this.#routedHost) {
|
|
2207
2476
|
this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
|
|
2208
2477
|
return;
|
|
@@ -2244,6 +2513,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2244
2513
|
isMouseTracking(sessionHandle) {
|
|
2245
2514
|
return this.#mouseTrackingByHandle.get(sessionHandle) ?? false;
|
|
2246
2515
|
}
|
|
2516
|
+
/** A session's mouse modes, from whichever path carried its frame: the legacy
|
|
2517
|
+
* bridge's header, read here, or the worker's word for a routed frame. */
|
|
2518
|
+
#noteMouseTracking(sessionHandle, tracking) {
|
|
2519
|
+
if (this.#mouseTrackingByHandle.get(sessionHandle) === tracking)
|
|
2520
|
+
return;
|
|
2521
|
+
this.#mouseTrackingByHandle.set(sessionHandle, tracking);
|
|
2522
|
+
this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
|
|
2523
|
+
}
|
|
2247
2524
|
setTheme(sessionHandle, theme, surfaceId) {
|
|
2248
2525
|
this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
|
|
2249
2526
|
// A surface-scoped theme is renderer-local. Updating the daemon's
|
|
@@ -2269,6 +2546,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2269
2546
|
setEffects(sessionHandle, effects, surfaceId) {
|
|
2270
2547
|
this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
|
|
2271
2548
|
}
|
|
2549
|
+
links(sessionHandle) {
|
|
2550
|
+
return this.#platform.openExternal
|
|
2551
|
+
? (this.#linksByHandle.get(sessionHandle) ?? []).filter((link) => link.explicit || this.#configSnapshot?.renderer.linkUrl !== false)
|
|
2552
|
+
: [];
|
|
2553
|
+
}
|
|
2554
|
+
async openLink(uri) {
|
|
2555
|
+
const url = terminalLinkUrl(uri);
|
|
2556
|
+
if (url)
|
|
2557
|
+
await this.#platform.openExternal?.(url);
|
|
2558
|
+
}
|
|
2272
2559
|
setSelection(sessionHandle, selection, surfaceId) {
|
|
2273
2560
|
this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
|
|
2274
2561
|
}
|
|
@@ -2303,13 +2590,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2303
2590
|
setPartialRenderingEnabled(enabled) {
|
|
2304
2591
|
this.#postWorker({ type: "partial-rendering", enabled });
|
|
2305
2592
|
}
|
|
2306
|
-
#claimRoutedGeometry(entry, viewId, cols, rows) {
|
|
2593
|
+
#claimRoutedGeometry(entry, viewId, cols, rows, trailing = false) {
|
|
2307
2594
|
const ticket = entry.ticket;
|
|
2308
2595
|
const view = this.#views.get(viewId);
|
|
2309
2596
|
if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
|
|
2310
2597
|
return;
|
|
2311
2598
|
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2312
|
-
|
|
2599
|
+
const viewSerial = view.routedGeometrySerial + 1;
|
|
2600
|
+
const sent = this.#routedControl?.claimGeometry(entry.state.activationId, {
|
|
2313
2601
|
sessionId: entry.sessionId,
|
|
2314
2602
|
activationId: entry.state.activationId,
|
|
2315
2603
|
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
@@ -2317,7 +2605,139 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2317
2605
|
cols,
|
|
2318
2606
|
rows,
|
|
2319
2607
|
expectRevision: geometry?.revision ?? 0,
|
|
2320
|
-
});
|
|
2608
|
+
}, new RoutedGeometryClaim(viewId, viewSerial, trailing));
|
|
2609
|
+
if (sent)
|
|
2610
|
+
view.routedGeometrySerial = viewSerial;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Overlapping claims from one view name the same revision, so the cell
|
|
2614
|
+
* commits the first and refuses the rest as stale: on a slow control leg
|
|
2615
|
+
* the last grid of a resize would never land. When the reply to a view's
|
|
2616
|
+
* latest claim or take (a commit, or a STALE_REVISION refusal) leaves the
|
|
2617
|
+
* seat with this view at a grid other than the one it shows, that grid goes
|
|
2618
|
+
* once more. Only for the holder, and never after a re-sent claim is
|
|
2619
|
+
* refused.
|
|
2620
|
+
*/
|
|
2621
|
+
#trailRoutedResize(entry, context, refusal) {
|
|
2622
|
+
const request = context instanceof RoutedGeometryClaim || context instanceof RoutedGeometryTake ? context : undefined;
|
|
2623
|
+
if (request === undefined)
|
|
2624
|
+
return;
|
|
2625
|
+
if (refusal !== undefined) {
|
|
2626
|
+
if (refusal !== "STALE_REVISION")
|
|
2627
|
+
return;
|
|
2628
|
+
if (request instanceof RoutedGeometryClaim && request.trailing)
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const view = this.#views.get(request.viewId);
|
|
2632
|
+
if (!view?.resizeControlRequested || view.routedGeometrySerial !== request.viewSerial)
|
|
2633
|
+
return;
|
|
2634
|
+
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
2635
|
+
return;
|
|
2636
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2637
|
+
if (!geometry || !this.#routedViewHolds(entry, geometry, request.viewId))
|
|
2638
|
+
return;
|
|
2639
|
+
if (geometry.cols === view.desiredCols && geometry.rows === view.desiredRows)
|
|
2640
|
+
return;
|
|
2641
|
+
this.#claimRoutedGeometry(entry, request.viewId, view.desiredCols, view.desiredRows, true);
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Send a take of the seat for `viewId`, built on the holder this runtime
|
|
2645
|
+
* last saw. False when it cannot go out: no ticket or attached control leg,
|
|
2646
|
+
* no `geometry` right, or the view no longer asks for resize control.
|
|
2647
|
+
*/
|
|
2648
|
+
#sendRoutedTake(entry, viewId, serial, attempt) {
|
|
2649
|
+
const ticket = entry.ticket;
|
|
2650
|
+
const view = this.#views.get(viewId);
|
|
2651
|
+
if (!ticket ||
|
|
2652
|
+
!entry.state.controlAttached ||
|
|
2653
|
+
!entry.state.rights.includes("geometry") ||
|
|
2654
|
+
!entry.viewIds.has(viewId) ||
|
|
2655
|
+
!view?.resizeControlRequested ||
|
|
2656
|
+
view.desiredCols === undefined ||
|
|
2657
|
+
view.desiredRows === undefined) {
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
const clientId = ticket.attachGrant.claims.clientId;
|
|
2661
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2662
|
+
const holder = geometry?.holder;
|
|
2663
|
+
const viewSerial = view.routedGeometrySerial + 1;
|
|
2664
|
+
const context = new RoutedGeometryTake(viewId, viewSerial, serial, attempt, geometry);
|
|
2665
|
+
const lease = {
|
|
2666
|
+
sessionId: entry.sessionId,
|
|
2667
|
+
activationId: entry.state.activationId,
|
|
2668
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2669
|
+
};
|
|
2670
|
+
const expectRevision = geometry?.revision ?? 0;
|
|
2671
|
+
const cols = view.desiredCols;
|
|
2672
|
+
const rows = view.desiredRows;
|
|
2673
|
+
// An empty seat is claimed; a claim of the view's own seat is committed
|
|
2674
|
+
// by the cell as a resize (at the same size, the PTY is left alone).
|
|
2675
|
+
const sent = holder === undefined || (holder.clientId === clientId && holder.viewId === viewId)
|
|
2676
|
+
? this.#routedControl?.claimGeometry(entry.state.activationId, { ...lease, claimant: { clientId, viewId }, cols, rows, expectRevision }, context)
|
|
2677
|
+
: this.#routedControl?.transferGeometry(entry.state.activationId, { ...lease, from: { ...holder }, to: { clientId, viewId }, expectRevision, cols, rows }, context);
|
|
2678
|
+
if (sent)
|
|
2679
|
+
view.routedGeometrySerial = viewSerial;
|
|
2680
|
+
return sent ?? false;
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Take the session's geometry seat for `viewId`: the host's policy verb.
|
|
2684
|
+
*
|
|
2685
|
+
* Focus is a scheduling fact. The runtime never turns it into geometry;
|
|
2686
|
+
* the host decides when a view takes the seat. VibeField's rule (DS-D5) is
|
|
2687
|
+
* that the pane focused last holds the PTY's cols×rows, on any device, last
|
|
2688
|
+
* write wins, and `TerminalSurface`'s `takeGeometryOnFocus` applies it.
|
|
2689
|
+
*
|
|
2690
|
+
* On the routed transport the take is built on the holder this runtime last
|
|
2691
|
+
* saw (`geometryHolder`):
|
|
2692
|
+
* - an empty seat draws a `ClaimGeometry`;
|
|
2693
|
+
* - a seat this view holds draws a `ClaimGeometry` of its own seat, which
|
|
2694
|
+
* the cell commits as a resize;
|
|
2695
|
+
* - a seat held by another view, of this client or another, draws one
|
|
2696
|
+
* `TransferGeometry {from: holder, to: {clientId, viewId}, expectRevision,
|
|
2697
|
+
* cols, rows}`, with `clientId` from this session's attach grant.
|
|
2698
|
+
*
|
|
2699
|
+
* A refusal is the only announcement a take gets. The cell answers only the
|
|
2700
|
+
* caller's control leg, so a pane whose seat another client took still
|
|
2701
|
+
* believes it holds it, and its take is a resize that the cell refuses. A
|
|
2702
|
+
* `SEAT_HELD`, `STALE_REVISION` or `NOT_HOLDER` refusal that names a holder
|
|
2703
|
+
* or revision other than the one the take was built on is retried once,
|
|
2704
|
+
* against what it names: one `TransferGeometry` from the named holder at
|
|
2705
|
+
* the named revision; a claim of the view's own seat if it names this
|
|
2706
|
+
* view; a `ClaimGeometry` if it names an empty seat. A refusal that repeats
|
|
2707
|
+
* the take's own view of the seat, or names no seat, is not retried, and
|
|
2708
|
+
* neither is a second refusal. The runtime owns that one retry: a host
|
|
2709
|
+
* calls this once per focus gain of a controller view and never retries
|
|
2710
|
+
* it. Only the session's latest take retries. A take asked before the
|
|
2711
|
+
* control leg attaches is sent when it attaches, unless the view's focus
|
|
2712
|
+
* ends first. Without the grant's `geometry` right nothing is sent.
|
|
2713
|
+
*
|
|
2714
|
+
* On the port-pair transport this is `claimResizeControl`, which never
|
|
2715
|
+
* displaces another view.
|
|
2716
|
+
*
|
|
2717
|
+
* Like `claimResizeControl`, it leaves the view asking for resize control:
|
|
2718
|
+
* its later grid changes resize the PTY while it holds the seat, until
|
|
2719
|
+
* `releaseResizeControl`.
|
|
2720
|
+
*/
|
|
2721
|
+
takeResizeControl(sessionHandle, viewId, cols, rows) {
|
|
2722
|
+
if (!this.#routedHost) {
|
|
2723
|
+
this.claimResizeControl(sessionHandle, viewId, cols, rows);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
const view = this.#views.get(viewId);
|
|
2727
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2728
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
2729
|
+
if (!view || !entry || view.sessionId !== entry.sessionId || !entry.viewIds.has(viewId))
|
|
2730
|
+
return;
|
|
2731
|
+
view.desiredCols = cols;
|
|
2732
|
+
view.desiredRows = rows;
|
|
2733
|
+
view.resizeControlRequested = true;
|
|
2734
|
+
entry.takeSerial += 1;
|
|
2735
|
+
const serial = entry.takeSerial;
|
|
2736
|
+
delete entry.queuedTake;
|
|
2737
|
+
if (this.#sendRoutedTake(entry, viewId, serial, 0))
|
|
2738
|
+
return;
|
|
2739
|
+
if (!entry.state.controlAttached)
|
|
2740
|
+
entry.queuedTake = { viewId, serial };
|
|
2321
2741
|
}
|
|
2322
2742
|
claimResizeControl(sessionHandle, viewId, cols, rows) {
|
|
2323
2743
|
const view = this.#views.get(viewId);
|
|
@@ -2344,9 +2764,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2344
2764
|
view.resizeControlRequested = false;
|
|
2345
2765
|
if (this.#routedHost) {
|
|
2346
2766
|
const entry = this.#routedBySession.get(view.sessionId);
|
|
2767
|
+
if (entry?.queuedTake?.viewId === viewId)
|
|
2768
|
+
delete entry.queuedTake;
|
|
2347
2769
|
const geometry = this.#routedGeometry.get(view.sessionId);
|
|
2348
2770
|
const ticket = entry?.ticket;
|
|
2349
|
-
|
|
2771
|
+
const holder = geometry?.holder;
|
|
2772
|
+
if (entry && ticket && geometry && holder && this.#routedViewHolds(entry, geometry, viewId)) {
|
|
2350
2773
|
const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
|
|
2351
2774
|
sessionId: view.sessionId,
|
|
2352
2775
|
activationId: entry.state.activationId,
|
|
@@ -2354,13 +2777,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2354
2777
|
holder: {
|
|
2355
2778
|
clientId: ticket.attachGrant.claims.clientId,
|
|
2356
2779
|
viewId,
|
|
2357
|
-
holderGeneration:
|
|
2780
|
+
holderGeneration: holder.holderGeneration,
|
|
2358
2781
|
},
|
|
2359
2782
|
});
|
|
2360
2783
|
if (sent) {
|
|
2361
2784
|
// T1 sends no success body for release. The ordered control leg and
|
|
2362
|
-
// cell state machine make the next revision deterministic
|
|
2363
|
-
|
|
2785
|
+
// cell state machine make the next revision deterministic; if the
|
|
2786
|
+
// seat had moved on, the cell's refusal names the holder instead.
|
|
2787
|
+
this.#setRoutedGeometry(entry, {
|
|
2364
2788
|
revision: geometry.revision + 1,
|
|
2365
2789
|
...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
|
|
2366
2790
|
...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
|
|
@@ -2369,9 +2793,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2369
2793
|
}
|
|
2370
2794
|
return;
|
|
2371
2795
|
}
|
|
2372
|
-
// The legacy protocol has no release verb
|
|
2373
|
-
//
|
|
2374
|
-
|
|
2796
|
+
// The legacy protocol has no release verb, so the daemon keeps this view
|
|
2797
|
+
// seated. Dropping the request is what closes every resize path; the epoch
|
|
2798
|
+
// stays, because it is still the seat's truth and a later explicit claim
|
|
2799
|
+
// resumes on it. Clearing it here would leave the view with the record in
|
|
2800
|
+
// its own name and no epoch: the funnel's one-claim-per-attachment guard
|
|
2801
|
+
// refuses that claim, and a pane hidden by a zoom never resizes again.
|
|
2375
2802
|
}
|
|
2376
2803
|
setViewInputPolicy(viewId, readWrite) {
|
|
2377
2804
|
const view = this.#views.get(viewId);
|
|
@@ -2406,8 +2833,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2406
2833
|
return;
|
|
2407
2834
|
if (this.#routedHost) {
|
|
2408
2835
|
const entry = this.#routedBySession.get(session.id);
|
|
2409
|
-
if (entry)
|
|
2410
|
-
|
|
2836
|
+
if (!entry)
|
|
2837
|
+
return;
|
|
2838
|
+
// The focus that asked for a take still waiting on the leg has ended.
|
|
2839
|
+
if (!focused && entry.queuedTake?.viewId === viewId)
|
|
2840
|
+
delete entry.queuedTake;
|
|
2841
|
+
this.#declareRoutedDemand(entry);
|
|
2411
2842
|
return;
|
|
2412
2843
|
}
|
|
2413
2844
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
|
|
@@ -2459,6 +2890,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2459
2890
|
return response.text;
|
|
2460
2891
|
}
|
|
2461
2892
|
interrupt(sessionId, viewId) {
|
|
2893
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2462
2894
|
if (this.#routedHost) {
|
|
2463
2895
|
this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
|
|
2464
2896
|
return;
|
|
@@ -2480,6 +2912,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2480
2912
|
this.#sessionMountReferences.delete(handle);
|
|
2481
2913
|
const subscriptionChanged = this.#subscribedSessionHandles.delete(handle);
|
|
2482
2914
|
this.#cancelMetadataRefresh(handle);
|
|
2915
|
+
this.#linksByHandle.delete(handle);
|
|
2483
2916
|
this.#mouseTrackingByHandle.delete(handle);
|
|
2484
2917
|
this.#scrollbarByHandle.delete(handle);
|
|
2485
2918
|
for (const mounted of [...this.#mountedEntries]) {
|
|
@@ -2494,7 +2927,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2494
2927
|
this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
|
|
2495
2928
|
this.#mountGenerationBySurface.delete(mounted.viewId);
|
|
2496
2929
|
if (detachViews)
|
|
2497
|
-
this.#
|
|
2930
|
+
this.#detachView(mounted.sessionId, mounted.viewId);
|
|
2498
2931
|
this.#views.delete(mounted.viewId);
|
|
2499
2932
|
this.#focusByView.delete(mounted.viewId);
|
|
2500
2933
|
}
|
|
@@ -2505,7 +2938,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2505
2938
|
if (view.sessionId === sessionId) {
|
|
2506
2939
|
view.pendingInput.length = 0;
|
|
2507
2940
|
if (detachViews)
|
|
2508
|
-
this.#
|
|
2941
|
+
this.#detachView(sessionId, viewId);
|
|
2509
2942
|
this.#views.delete(viewId);
|
|
2510
2943
|
this.#focusByView.delete(viewId);
|
|
2511
2944
|
}
|
|
@@ -2517,6 +2950,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2517
2950
|
this.#remoteSessions.delete(sessionId);
|
|
2518
2951
|
this.#controlBySession.delete(sessionId);
|
|
2519
2952
|
this.#recoveredSessions.delete(sessionId);
|
|
2953
|
+
// The worker forgets the handle's first frame with its snapshot (`drop-session`).
|
|
2954
|
+
this.#sessionsDrawn.delete(sessionId);
|
|
2520
2955
|
if (subscriptionChanged) {
|
|
2521
2956
|
this.#frameSubscriptionVersion += 1;
|
|
2522
2957
|
this.#syncFrameSubscriptionsInBackground();
|
|
@@ -2615,6 +3050,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2615
3050
|
this.#recoveredSessions.clear();
|
|
2616
3051
|
this.#focusByView.clear();
|
|
2617
3052
|
this.#mountGenerationBySurface.clear();
|
|
3053
|
+
this.#linksByHandle.clear();
|
|
2618
3054
|
this.#mouseTrackingByHandle.clear();
|
|
2619
3055
|
this.#scrollbarByHandle.clear();
|
|
2620
3056
|
this.#subscribedSessionHandles.clear();
|
|
@@ -2635,6 +3071,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2635
3071
|
window.clearTimeout(entry.attachTimer);
|
|
2636
3072
|
if (entry.renewalTimer !== undefined)
|
|
2637
3073
|
window.clearTimeout(entry.renewalTimer);
|
|
3074
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
3075
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
3076
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
3077
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
2638
3078
|
}
|
|
2639
3079
|
this.#routedBySession.clear();
|
|
2640
3080
|
this.#routedByActivation.clear();
|