tmux-ide 2.9.4 → 2.9.5
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/bin/cli.js +4354 -3376
- package/package.json +7 -6
- package/packages/contracts/src/session-runtime.ts +2 -0
- package/packages/contracts/src/terminal-delivery.ts +4 -0
- package/packages/core/src/terminal-compact-capability.ts +31 -1
- package/packages/core/src/terminal-delivery.ts +234 -32
- package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
- package/packages/core/src/terminal-replica-owned-row.ts +165 -10
- package/packages/core/src/terminal-replica.ts +43 -21
- package/packages/daemon/dist/lib/daemon-embed.js +19 -34
- package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
- package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
- package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
- package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
- package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
- package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
- package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
- package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
- package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
- package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
- package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
- package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
- package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
- package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
- package/packages/daemon/src/lib/daemon-embed.ts +22 -34
- package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
- package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
- package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
- package/packages/daemon/src/terminal/mirror/events.ts +7 -7
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
- package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
- package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
- package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
- package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
- package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
- package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
- package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
- package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
- package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
- package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
- package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
- package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
- package/packages/daemon-client/src/workspace-client-types.ts +1 -0
- package/packages/daemon-client/src/workspace-client.ts +78 -5
- package/packages/tmux-bridge/src/index.ts +1 -0
- package/packages/tmux-bridge/src/runner.ts +25 -1
|
@@ -30,7 +30,7 @@ import { decodeNativeGridCapture, isNativeBootstrapCapture, } from "./native-gri
|
|
|
30
30
|
* keys flush pending literals first — the shared {@link InputCoalescer}
|
|
31
31
|
* discipline — leaving fire-and-forget via the channel.
|
|
32
32
|
*/
|
|
33
|
-
import {
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
34
|
import { hostname } from "node:os";
|
|
35
35
|
import { WINDOW_LINK_MAX_LINKS, WORKSPACE_SEMANTIC_PANE_OPTION, WORKSPACE_SEMANTIC_WINDOW_OPTION, WorkspaceIdSchemaZ, } from "@tmux-ide/contracts";
|
|
36
36
|
import { textToHexKeys } from "../protocol/control.js";
|
|
@@ -45,6 +45,7 @@ import { WindowLinkAuthority, WindowLinkResolutionError } from "./window-link-au
|
|
|
45
45
|
import { buildNativeWindowLinkGuard, buildNativeWindowLinkPaneSelectGuard, classifyNativeWindowLinkGuardResult, } from "../../lib/tmux-window-link-guard.js";
|
|
46
46
|
import { FlowLedger } from "./flow-ledger.js";
|
|
47
47
|
import { PaneFeed, captureLinesFromAnsiBytes, parseCursorProbe } from "./pane-feed.js";
|
|
48
|
+
import { StockPaneSnapshot } from "./stock-pane-snapshot.js";
|
|
48
49
|
import { INTERNAL_READ_OPERATION_OPTION, registerInternalReadOperation, retireInternalReadOperation, } from "../../lib/tmux-interaction-options.js";
|
|
49
50
|
const TMUX_SERVER_HOSTNAME = hostname();
|
|
50
51
|
/** Notifications whose payload cannot be applied directly — fall back to the
|
|
@@ -69,20 +70,17 @@ const NATIVE_CLIENT_SUBSCRIPTION = "tmux-ide-native-clients";
|
|
|
69
70
|
const SYNC_DEBOUNCE_MS = 40;
|
|
70
71
|
/** Foreground-command labels follow output, but never turn a busy pane into a probe loop. */
|
|
71
72
|
const DISPLAY_NAME_SYNC_INTERVAL_MS = 750;
|
|
72
|
-
const RECOVERY_QUIET_MS = 40;
|
|
73
73
|
const RECOVERY_COMMAND_DEADLINE_MS = 500;
|
|
74
74
|
const RECOVERY_NO_PROGRESS_DEADLINE_MS = 3_000;
|
|
75
75
|
const RECOVERY_ABSOLUTE_DEADLINE_MS = 5_000;
|
|
76
76
|
const RECOVERY_MAX_ATTEMPTS = 4;
|
|
77
|
-
const
|
|
77
|
+
const MAX_QUEUED_SNAPSHOTS = 64;
|
|
78
78
|
const RECOVERY_CAPTURE_MAX_BYTES = 16 * 1024 * 1024;
|
|
79
79
|
// Budget row bookkeeping separately from wire bytes (64 bytes per retained
|
|
80
80
|
// line). A fixed 8192-row ceiling rejected small captures of ordinary history.
|
|
81
81
|
// Both the wire-byte cap and this finite allocation bound remain enforced.
|
|
82
82
|
const RECOVERY_CAPTURE_MAX_LINES = RECOVERY_CAPTURE_MAX_BYTES / 64;
|
|
83
83
|
const RECOVERY_CURSOR_MAX_BYTES = 1_024;
|
|
84
|
-
const MAX_CONTINUE_NOTIFICATION_QUEUE = 32;
|
|
85
|
-
const MAX_CONTINUE_NOTIFICATION_DEBT = 65_536;
|
|
86
84
|
const RECOVERY_CURSOR_PROBE_FORMAT = [
|
|
87
85
|
"#{cursor_x}",
|
|
88
86
|
"#{cursor_y}",
|
|
@@ -125,24 +123,6 @@ const FAILED_RESEED_RESULT = Object.freeze({
|
|
|
125
123
|
publish: () => false,
|
|
126
124
|
hold: () => { },
|
|
127
125
|
});
|
|
128
|
-
function snapshotFingerprint(captureLines, cursorLine, fallbackSize) {
|
|
129
|
-
const hash = createHash("sha256");
|
|
130
|
-
const append = (bytes) => {
|
|
131
|
-
const length = Buffer.allocUnsafe(4);
|
|
132
|
-
length.writeUInt32BE(bytes.byteLength);
|
|
133
|
-
hash.update(length);
|
|
134
|
-
hash.update(bytes);
|
|
135
|
-
};
|
|
136
|
-
hash.update("tmux-ide/recovery-snapshot/v1\0");
|
|
137
|
-
const count = Buffer.allocUnsafe(4);
|
|
138
|
-
count.writeUInt32BE(captureLines.length);
|
|
139
|
-
hash.update(count);
|
|
140
|
-
for (const line of captureLines)
|
|
141
|
-
append(Buffer.from(line, "latin1"));
|
|
142
|
-
append(Buffer.from(cursorLine, "utf8"));
|
|
143
|
-
append(Buffer.from(fallbackSize ? `${fallbackSize.cols}x${fallbackSize.rows}` : "no-layout-fallback", "ascii"));
|
|
144
|
-
return hash.digest("hex");
|
|
145
|
-
}
|
|
146
126
|
// tmux concatenates adjacent quoted/unquoted fragments; doubled single quotes
|
|
147
127
|
// do not escape a quote. Preserve nested commands across both parser passes.
|
|
148
128
|
function tmuxSingleQuote(value) {
|
|
@@ -163,7 +143,6 @@ export function defaultMirrorWindowId() {
|
|
|
163
143
|
export class SessionChannel {
|
|
164
144
|
nativeBootstrapUnavailable = false;
|
|
165
145
|
nativeBootstrapConfirmed = false;
|
|
166
|
-
nativeProbeRetried = new WeakSet();
|
|
167
146
|
opts;
|
|
168
147
|
io;
|
|
169
148
|
ledger = new FlowLedger();
|
|
@@ -190,6 +169,7 @@ export class SessionChannel {
|
|
|
190
169
|
fittedWindows = new Map();
|
|
191
170
|
cancelSync = null;
|
|
192
171
|
syncOrdinal = 0;
|
|
172
|
+
completedSyncOrdinal = 0;
|
|
193
173
|
lastDisplayNameSyncAtMs = 0;
|
|
194
174
|
disposed = false;
|
|
195
175
|
nativeGrid;
|
|
@@ -200,12 +180,12 @@ export class SessionChannel {
|
|
|
200
180
|
windowIdentityOrdinal = 0;
|
|
201
181
|
paneIncarnation = 0;
|
|
202
182
|
recoveryOrdinal = 0;
|
|
203
|
-
|
|
204
|
-
|
|
183
|
+
snapshotQueue = new Map();
|
|
184
|
+
snapshotActive = null;
|
|
185
|
+
observedPaused = new Set();
|
|
205
186
|
outputOrdinals = new Map();
|
|
206
187
|
pendingLayoutOutput = new Map();
|
|
207
188
|
recoveries = new Map();
|
|
208
|
-
continueNotificationQueues = new Map();
|
|
209
189
|
trustedInventoryFlight = null;
|
|
210
190
|
trustedInventoryFlightSessionId = null;
|
|
211
191
|
attachedIdentity = null;
|
|
@@ -218,6 +198,19 @@ export class SessionChannel {
|
|
|
218
198
|
input = new InputCoalescer((action) => {
|
|
219
199
|
const startedAtMicros = action.traceIds?.length ? Math.floor(performance.now() * 1_000) : 0;
|
|
220
200
|
const pendingBeforeSend = action.traceIds?.length ? (this.io.pendingCount ?? 0) : 0;
|
|
201
|
+
const pane = action.traceIds?.length ? this.panesByRuntime.get(action.pane) : undefined;
|
|
202
|
+
const semanticPaneId = !this.disposed && pane?.descriptor ? pane.semanticId : null;
|
|
203
|
+
const paneIncarnation = pane?.incarnation;
|
|
204
|
+
const paneActive = pane?.active;
|
|
205
|
+
const birth = pane?.descriptor?.nativePaneBirthId;
|
|
206
|
+
let complete;
|
|
207
|
+
try {
|
|
208
|
+
if (action.traceIds?.length)
|
|
209
|
+
complete = this.opts.captureInputWrite?.(action, semanticPaneId);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Optional diagnostics must never suppress a dispatch.
|
|
213
|
+
}
|
|
221
214
|
const onReply = action.traceIds?.length
|
|
222
215
|
? (reply) => this.opts.onInputAccepted?.(action, Math.floor(performance.now() * 1_000), reply.ok)
|
|
223
216
|
: undefined;
|
|
@@ -234,6 +227,22 @@ export class SessionChannel {
|
|
|
234
227
|
else {
|
|
235
228
|
this.io.send(`send-keys -t ${action.pane} ${action.key}`, onReply);
|
|
236
229
|
}
|
|
230
|
+
try {
|
|
231
|
+
complete?.(startedAtMicros, Math.floor(performance.now() * 1_000), pendingBeforeSend, !this.disposed &&
|
|
232
|
+
pane !== undefined &&
|
|
233
|
+
semanticPaneId !== null &&
|
|
234
|
+
this.panesByRuntime.get(action.pane) === pane &&
|
|
235
|
+
this.panesBySemantic.get(semanticPaneId) === pane &&
|
|
236
|
+
pane.runtimeId === action.pane &&
|
|
237
|
+
pane.semanticId === semanticPaneId &&
|
|
238
|
+
pane.incarnation === paneIncarnation &&
|
|
239
|
+
pane.active === paneActive &&
|
|
240
|
+
pane.descriptor !== null &&
|
|
241
|
+
pane.descriptor.nativePaneBirthId === birth);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// In particular, never replay an accepted owned-native dispatch.
|
|
245
|
+
}
|
|
237
246
|
if (action.traceIds?.length)
|
|
238
247
|
this.opts.onInputWrite?.(action, startedAtMicros, Math.floor(performance.now() * 1_000), pendingBeforeSend);
|
|
239
248
|
}, (flush) => queueMicrotask(flush));
|
|
@@ -308,8 +317,12 @@ export class SessionChannel {
|
|
|
308
317
|
this.captureWithViewer(runtime, ["-R", "-S", "-"], (value) => {
|
|
309
318
|
marker = value;
|
|
310
319
|
}, (reply) => {
|
|
311
|
-
if (
|
|
312
|
-
|
|
320
|
+
if (marker) {
|
|
321
|
+
if (reply.ok)
|
|
322
|
+
this.clearInternalReadMarkerOption(runtime, marker);
|
|
323
|
+
else
|
|
324
|
+
this.retireInternalReadMarker(runtime, marker);
|
|
325
|
+
}
|
|
313
326
|
onReply(reply);
|
|
314
327
|
}, limits);
|
|
315
328
|
}
|
|
@@ -610,6 +623,7 @@ export class SessionChannel {
|
|
|
610
623
|
frozen: false,
|
|
611
624
|
closed: false,
|
|
612
625
|
};
|
|
626
|
+
sub.feed.abortCurrent();
|
|
613
627
|
pane.subs.add(sub);
|
|
614
628
|
// A paused pane gains an unfrozen watcher: release the park before the
|
|
615
629
|
// seed so the capture reflects a flowing pane.
|
|
@@ -657,6 +671,13 @@ export class SessionChannel {
|
|
|
657
671
|
close: () => this.closeSub(sub),
|
|
658
672
|
};
|
|
659
673
|
}
|
|
674
|
+
qualificationListeners() {
|
|
675
|
+
return Object.freeze({
|
|
676
|
+
pane: [...this.panesByRuntime.values()].reduce((sum, pane) => sum + pane.subs.size, 0),
|
|
677
|
+
layout: this.layoutSubscribers.size,
|
|
678
|
+
layoutAuthority: this.layoutAuthoritySubscribers.size,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
660
681
|
/** Session geometry without a dummy pane feed or terminal-content seed. */
|
|
661
682
|
subscribeLayout(onLayout) {
|
|
662
683
|
if (this.disposed)
|
|
@@ -863,6 +884,10 @@ export class SessionChannel {
|
|
|
863
884
|
if (this.disposed)
|
|
864
885
|
return;
|
|
865
886
|
this.disposed = true;
|
|
887
|
+
this.snapshotActive?.cleanup?.();
|
|
888
|
+
this.snapshotActive?.cancelFence?.();
|
|
889
|
+
this.snapshotActive = null;
|
|
890
|
+
this.snapshotQueue.clear();
|
|
866
891
|
this.windowLinkAuthority?.dispose();
|
|
867
892
|
this.nativeGrid.dispose();
|
|
868
893
|
this.settleFirstJoin();
|
|
@@ -870,7 +895,6 @@ export class SessionChannel {
|
|
|
870
895
|
this.cancelSync = null;
|
|
871
896
|
for (const runtime of [...this.recoveries.keys()])
|
|
872
897
|
this.cancelRecovery(runtime);
|
|
873
|
-
this.continueNotificationQueues.clear();
|
|
874
898
|
this.discovery.dispose();
|
|
875
899
|
this.input.flush();
|
|
876
900
|
this.opts.ownedViewer?.dispose();
|
|
@@ -879,7 +903,12 @@ export class SessionChannel {
|
|
|
879
903
|
if (!sub.closed) {
|
|
880
904
|
sub.closed = true;
|
|
881
905
|
sub.cancelCapture?.();
|
|
882
|
-
|
|
906
|
+
try {
|
|
907
|
+
sub.onEvent({ type: "closed" });
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
911
|
+
}
|
|
883
912
|
}
|
|
884
913
|
}
|
|
885
914
|
pane.subs.clear();
|
|
@@ -892,6 +921,24 @@ export class SessionChannel {
|
|
|
892
921
|
}
|
|
893
922
|
// ── Byte routing ─────────────────────────────────────────────────────────
|
|
894
923
|
onOutput(runtimePane, data, ageMs, timing) {
|
|
924
|
+
const stockRecovery = this.recoveries.get(runtimePane);
|
|
925
|
+
const stockPane = this.panesByRuntime.get(runtimePane);
|
|
926
|
+
if (stockRecovery?.stock && stockPane && stockRecovery.lease) {
|
|
927
|
+
const disposition = stockRecovery.stock.acceptOutput(runtimePane, data, this.snapshotContext(stockPane));
|
|
928
|
+
if (disposition !== "live" && disposition !== "unrelated") {
|
|
929
|
+
const ordinal = (this.outputOrdinals.get(runtimePane) ?? 0) + 1;
|
|
930
|
+
this.outputOrdinals.set(runtimePane, ordinal);
|
|
931
|
+
this.opts.onOutputObserved?.(stockPane.semanticId, ageMs, timing);
|
|
932
|
+
if (ageMs !== null) {
|
|
933
|
+
this.ageByRuntime.set(runtimePane, ageMs);
|
|
934
|
+
if (ageMs > this.maxAgeMs)
|
|
935
|
+
this.maxAgeMs = ageMs;
|
|
936
|
+
}
|
|
937
|
+
if (disposition === "invalid")
|
|
938
|
+
this.retrySnapshot(stockRecovery);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
895
942
|
const windowId = this.panesByRuntime.get(runtimePane)?.windowRuntimeId;
|
|
896
943
|
const pending = windowId ? this.pendingLayoutOutput.get(windowId) : undefined;
|
|
897
944
|
if (pending) {
|
|
@@ -933,328 +980,26 @@ export class SessionChannel {
|
|
|
933
980
|
}
|
|
934
981
|
if (overflowed)
|
|
935
982
|
this.restartRecoveryAfterOutputOverflow(pane);
|
|
936
|
-
this.noteRecoveryOutput(pane, outputOrdinal);
|
|
937
983
|
}
|
|
938
984
|
// ── Seed / reseed (the atomic recipe) ────────────────────────────────────
|
|
939
|
-
reseed(lease, onSettled, deferPublish = false, deadlineAt = this.recoveryNowMs() + RECOVERY_ABSOLUTE_DEADLINE_MS) {
|
|
940
|
-
const { sub } = lease;
|
|
941
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
942
|
-
onSettled?.(FAILED_RESEED_RESULT);
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
lease.cancelRecipe?.();
|
|
946
|
-
const runtime = sub.pane.runtimeId;
|
|
947
|
-
const epoch = sub.feed.beginReseed();
|
|
948
|
-
let settled = false;
|
|
949
|
-
let captureSucceeded = false;
|
|
950
|
-
let markerRetired = false;
|
|
951
|
-
let captureLines = null;
|
|
952
|
-
let capturedNativeSize = null;
|
|
953
|
-
let cancelDeadline = null;
|
|
954
|
-
let resumeLayoutCapture = null;
|
|
955
|
-
const clearLayoutWait = () => {
|
|
956
|
-
if (sub.resumeLayoutCapture === resumeLayoutCapture)
|
|
957
|
-
sub.resumeLayoutCapture = null;
|
|
958
|
-
resumeLayoutCapture = null;
|
|
959
|
-
};
|
|
960
|
-
const settle = (result) => {
|
|
961
|
-
if (settled)
|
|
962
|
-
return;
|
|
963
|
-
settled = true;
|
|
964
|
-
clearLayoutWait();
|
|
965
|
-
cancelDeadline?.();
|
|
966
|
-
lease.cancelRecipe = null;
|
|
967
|
-
onSettled?.(result);
|
|
968
|
-
};
|
|
969
|
-
// Keystroke ordering: pending coalesced input leaves before the probes.
|
|
970
|
-
this.input.flush();
|
|
971
|
-
const history = this.opts.historyLines ?? "";
|
|
972
|
-
let internalReadMarker = null;
|
|
973
|
-
const retireMarker = () => {
|
|
974
|
-
if (markerRetired)
|
|
975
|
-
return;
|
|
976
|
-
markerRetired = true;
|
|
977
|
-
if (internalReadMarker)
|
|
978
|
-
this.retireInternalReadMarker(runtime, internalReadMarker);
|
|
979
|
-
};
|
|
980
|
-
lease.cancelRecipe = () => {
|
|
981
|
-
if (settled)
|
|
982
|
-
return;
|
|
983
|
-
settled = true;
|
|
984
|
-
clearLayoutWait();
|
|
985
|
-
cancelDeadline?.();
|
|
986
|
-
lease.cancelRecipe = null;
|
|
987
|
-
sub.feed.abort(epoch);
|
|
988
|
-
if (!captureSucceeded)
|
|
989
|
-
retireMarker();
|
|
990
|
-
};
|
|
991
|
-
cancelDeadline = this.scheduleRecovery(() => {
|
|
992
|
-
if (settled)
|
|
993
|
-
return;
|
|
994
|
-
sub.feed.abort(epoch);
|
|
995
|
-
if (!captureSucceeded)
|
|
996
|
-
retireMarker();
|
|
997
|
-
settle(FAILED_RESEED_RESULT);
|
|
998
|
-
}, Math.max(0, deadlineAt - this.recoveryNowMs()));
|
|
999
|
-
// Keep retired reply slots in the control FIFO; their callbacks become
|
|
1000
|
-
// no-ops so late responses cannot publish or consume a newer capture.
|
|
1001
|
-
// Both probes ride one write burst; the FIFO reply order is the seam.
|
|
1002
|
-
this.captureWithViewer(runtime, [...(sub.nativeBootstrap ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`], (marker) => {
|
|
1003
|
-
internalReadMarker = marker;
|
|
1004
|
-
}, (reply) => {
|
|
1005
|
-
if (settled)
|
|
1006
|
-
return;
|
|
1007
|
-
const native = sub.nativeBootstrap && reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
|
|
1008
|
-
if (sub.nativeBootstrap &&
|
|
1009
|
-
(!native || !isNativeBootstrapCapture(native)) &&
|
|
1010
|
-
!nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
|
|
1011
|
-
if (!this.nativeBootstrapConfirmed &&
|
|
1012
|
-
!this.nativeProbeRetried.has(sub) &&
|
|
1013
|
-
this.recoveryNowMs() < deadlineAt) {
|
|
1014
|
-
// An unknown server can swallow an unsupported command inside a
|
|
1015
|
-
// dynamic hook. Retry the ordinary probe once, with a fresh FIFO
|
|
1016
|
-
// seam and the original deadline, before invoking native recovery.
|
|
1017
|
-
this.nativeProbeRetried.add(sub);
|
|
1018
|
-
settled = true;
|
|
1019
|
-
sub.feed.abort(epoch);
|
|
1020
|
-
cancelDeadline?.();
|
|
1021
|
-
lease.cancelRecipe = null;
|
|
1022
|
-
retireMarker();
|
|
1023
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
sub.feed.abort(epoch);
|
|
1027
|
-
retireMarker();
|
|
1028
|
-
settle(FAILED_RESEED_RESULT);
|
|
1029
|
-
return;
|
|
1030
|
-
}
|
|
1031
|
-
if (sub.nativeBootstrap && (!native || !isNativeBootstrapCapture(native))) {
|
|
1032
|
-
// Unsupported/backing-only native exports fall back at a new FIFO
|
|
1033
|
-
// capture seam. Never replay held bytes across these two captures.
|
|
1034
|
-
settled = true;
|
|
1035
|
-
retireMarker();
|
|
1036
|
-
sub.feed.abort(epoch);
|
|
1037
|
-
cancelDeadline?.();
|
|
1038
|
-
lease.cancelRecipe = null;
|
|
1039
|
-
sub.nativeBootstrap = false;
|
|
1040
|
-
this.nativeBootstrapUnavailable = true;
|
|
1041
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1042
|
-
return;
|
|
1043
|
-
}
|
|
1044
|
-
if (!reply.ok) {
|
|
1045
|
-
// Successful captures consume the marker atomically inside the tmux
|
|
1046
|
-
// after-capture-pane hook. The command-list also prevents a concurrent
|
|
1047
|
-
// mirror from stealing the marker. Only failures need cleanup.
|
|
1048
|
-
retireMarker();
|
|
1049
|
-
sub.feed.abort(epoch);
|
|
1050
|
-
settle(FAILED_RESEED_RESULT);
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
captureSucceeded = true;
|
|
1054
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
1055
|
-
sub.feed.abort(epoch);
|
|
1056
|
-
settle(FAILED_RESEED_RESULT);
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
captureLines = [...reply.lines];
|
|
1060
|
-
if (native) {
|
|
1061
|
-
this.nativeBootstrapConfirmed = true;
|
|
1062
|
-
capturedNativeSize = { cols: native.cols, rows: native.rows };
|
|
1063
|
-
}
|
|
1064
|
-
if (native)
|
|
1065
|
-
sub.feed.captureNativeReply(epoch, native);
|
|
1066
|
-
else
|
|
1067
|
-
sub.feed.captureReply(epoch, reply.lines);
|
|
1068
|
-
});
|
|
1069
|
-
this.io.commandInline(`display-message -p -t ${runtime} "${RECOVERY_CURSOR_PROBE_FORMAT}"`, (reply) => {
|
|
1070
|
-
if (settled)
|
|
1071
|
-
return;
|
|
1072
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
1073
|
-
sub.feed.abort(epoch);
|
|
1074
|
-
settle(FAILED_RESEED_RESULT);
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
if (!reply.ok) {
|
|
1078
|
-
if (!captureSucceeded)
|
|
1079
|
-
retireMarker();
|
|
1080
|
-
sub.feed.abort(epoch);
|
|
1081
|
-
settle(FAILED_RESEED_RESULT);
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
// Native/cursor replies can precede the paired layout/border reply.
|
|
1085
|
-
// Never publish that candidate against old authority, nor replay held
|
|
1086
|
-
// output over its snapshot. Recapture after the latest layout releases,
|
|
1087
|
-
// retaining this recipe's original deadline and one queue lease.
|
|
1088
|
-
const windowId = sub.pane.windowRuntimeId;
|
|
1089
|
-
const probe = parseCursorProbe(reply.lines[0] ?? "");
|
|
1090
|
-
const validCaptureGeometry = probe &&
|
|
1091
|
-
Number.isSafeInteger(probe.x) &&
|
|
1092
|
-
Number.isSafeInteger(probe.y) &&
|
|
1093
|
-
probe.y < probe.rows &&
|
|
1094
|
-
(!capturedNativeSize ||
|
|
1095
|
-
(capturedNativeSize.cols === probe.cols && capturedNativeSize.rows === probe.rows));
|
|
1096
|
-
const layoutSize = validCaptureGeometry ? this.layoutCaptureSizeFor(sub) : null;
|
|
1097
|
-
const pendingLayout = windowId !== null && this.pendingLayoutOutput.has(windowId);
|
|
1098
|
-
const aheadOfLayout = layoutSize && probe && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows);
|
|
1099
|
-
if (windowId && layoutSize && validCaptureGeometry && (pendingLayout || aheadOfLayout)) {
|
|
1100
|
-
sub.feed.abort(epoch);
|
|
1101
|
-
captureLines = null;
|
|
1102
|
-
const waitingAfterSyncOrdinal = this.syncOrdinal;
|
|
1103
|
-
resumeLayoutCapture = (completedSyncOrdinal) => {
|
|
1104
|
-
if (settled)
|
|
1105
|
-
return;
|
|
1106
|
-
if (this.recoveryNowMs() >= deadlineAt) {
|
|
1107
|
-
settle(FAILED_RESEED_RESULT);
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
const current = this.layoutCaptureSizeFor(sub);
|
|
1111
|
-
// Only a successful truth sync started after this wait is a causal
|
|
1112
|
-
// barrier, including resize-back to identical geometry. An older
|
|
1113
|
-
// in-flight sync and cached layout emissions cannot qualify it.
|
|
1114
|
-
if (!current)
|
|
1115
|
-
return;
|
|
1116
|
-
if (completedSyncOrdinal !== undefined) {
|
|
1117
|
-
if (completedSyncOrdinal <= waitingAfterSyncOrdinal)
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
else if (!pendingLayout &&
|
|
1121
|
-
current.cols === layoutSize.cols &&
|
|
1122
|
-
current.rows === layoutSize.rows)
|
|
1123
|
-
return;
|
|
1124
|
-
settled = true;
|
|
1125
|
-
clearLayoutWait();
|
|
1126
|
-
cancelDeadline?.();
|
|
1127
|
-
lease.cancelRecipe = null;
|
|
1128
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1129
|
-
};
|
|
1130
|
-
sub.resumeLayoutCapture = resumeLayoutCapture;
|
|
1131
|
-
if (!pendingLayout)
|
|
1132
|
-
this.scheduleSync();
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
const cursorLine = reply.lines[0] ?? "";
|
|
1136
|
-
const historySize = Number(cursorLine.trim().split(/\s+/)[14]);
|
|
1137
|
-
if (Number.isSafeInteger(historySize) && historySize >= 0)
|
|
1138
|
-
sub.pane.historySize = historySize;
|
|
1139
|
-
const fallbackSize = this.layoutSizeFor(runtime);
|
|
1140
|
-
this.observeScrollOnClear(sub.pane, cursorLine);
|
|
1141
|
-
const events = sub.feed.cursorReply(epoch, cursorLine, fallbackSize);
|
|
1142
|
-
let published = false;
|
|
1143
|
-
const publish = () => {
|
|
1144
|
-
if (published)
|
|
1145
|
-
return true;
|
|
1146
|
-
if (sub.closed || sub.frozen || this.disposed)
|
|
1147
|
-
return false;
|
|
1148
|
-
published = true;
|
|
1149
|
-
for (const event of events)
|
|
1150
|
-
sub.onEvent(event);
|
|
1151
|
-
return true;
|
|
1152
|
-
};
|
|
1153
|
-
const ok = events.length > 0 && !sub.closed && captureLines !== null;
|
|
1154
|
-
const result = {
|
|
1155
|
-
ok,
|
|
1156
|
-
fingerprint: ok ? snapshotFingerprint(captureLines, cursorLine, fallbackSize) : null,
|
|
1157
|
-
publish,
|
|
1158
|
-
hold: () => sub.feed.quarantine(epoch),
|
|
1159
|
-
};
|
|
1160
|
-
if (!deferPublish)
|
|
1161
|
-
publish();
|
|
1162
|
-
settle(result);
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
985
|
reseedPlain(sub, resumeReason = null) {
|
|
1166
986
|
if (sub.closed || sub.frozen || this.disposed)
|
|
1167
987
|
return;
|
|
1168
|
-
|
|
1169
|
-
// waiting recipe already owns that request and its absolute deadline.
|
|
1170
|
-
if (sub.resumeLayoutCapture)
|
|
1171
|
-
return;
|
|
1172
|
-
if (this.plainReseedActive?.sub === sub)
|
|
1173
|
-
sub.cancelCapture?.();
|
|
1174
|
-
if (!this.plainReseedQueue.has(sub) && this.plainReseedQueue.size >= MAX_QUEUED_PLAIN_RESEEDS) {
|
|
1175
|
-
this.failPlainReseed(sub);
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
this.plainReseedQueue.set(sub, resumeReason);
|
|
1179
|
-
if (this.plainReseedActive?.sub !== sub) {
|
|
1180
|
-
// Output before this queued recipe starts is already in its future
|
|
1181
|
-
// capture. Do not publish it using the prior geometry in the meantime.
|
|
1182
|
-
sub.feed.beginReseed();
|
|
1183
|
-
sub.cancelCapture = () => {
|
|
1184
|
-
this.plainReseedQueue.delete(sub);
|
|
1185
|
-
sub.cancelCapture = null;
|
|
1186
|
-
sub.feed.abortCurrent();
|
|
1187
|
-
};
|
|
1188
|
-
}
|
|
1189
|
-
this.drainPlainReseeds();
|
|
1190
|
-
}
|
|
1191
|
-
failPlainReseed(sub) {
|
|
1192
|
-
if (sub.closed ||
|
|
1193
|
-
sub.frozen ||
|
|
1194
|
-
this.disposed ||
|
|
1195
|
-
this.recoveries.has(sub.pane.runtimeId) ||
|
|
1196
|
-
this.panesByRuntime.get(sub.pane.runtimeId) !== sub.pane)
|
|
1197
|
-
return;
|
|
1198
|
-
this.beginLocalOverflowRecovery(sub.pane);
|
|
1199
|
-
}
|
|
1200
|
-
drainPlainReseeds() {
|
|
1201
|
-
if (this.plainReseedActive || this.disposed)
|
|
1202
|
-
return;
|
|
1203
|
-
const next = this.plainReseedQueue.entries().next().value;
|
|
1204
|
-
if (!next)
|
|
1205
|
-
return;
|
|
1206
|
-
const [sub, resumeReason] = next;
|
|
1207
|
-
this.plainReseedQueue.delete(sub);
|
|
1208
|
-
if (sub.closed || sub.frozen) {
|
|
1209
|
-
this.drainPlainReseeds();
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
sub.cancelCapture?.();
|
|
1213
|
-
const lease = { sub, cancelRecipe: null };
|
|
1214
|
-
this.plainReseedActive = lease;
|
|
1215
|
-
// The queue lease outlives individual native capability probes. Retrying
|
|
1216
|
-
// or falling back replaces only cancelRecipe, never this queue owner.
|
|
1217
|
-
sub.cancelCapture = () => {
|
|
1218
|
-
if (this.plainReseedActive !== lease)
|
|
1219
|
-
return;
|
|
1220
|
-
lease.cancelRecipe?.();
|
|
1221
|
-
sub.cancelCapture = null;
|
|
1222
|
-
this.plainReseedActive = null;
|
|
1223
|
-
this.drainPlainReseeds();
|
|
1224
|
-
};
|
|
1225
|
-
// Only the recipe actually submitted to the control FIFO owns a five
|
|
1226
|
-
// second deadline. A large sibling projection cannot consume the waiting
|
|
1227
|
-
// panes' entire capture budgets before their commands have been written.
|
|
1228
|
-
this.reseed(lease, (result) => {
|
|
1229
|
-
if (this.plainReseedActive !== lease)
|
|
1230
|
-
return;
|
|
1231
|
-
sub.cancelCapture = null;
|
|
1232
|
-
this.plainReseedActive = null;
|
|
1233
|
-
if (!result.ok) {
|
|
1234
|
-
// A stalled FIFO cannot make progress for queued recipes either. Retire
|
|
1235
|
-
// them together instead of granting each another wait behind that slot.
|
|
1236
|
-
const waiting = [...this.plainReseedQueue.keys()];
|
|
1237
|
-
this.plainReseedQueue.clear();
|
|
1238
|
-
for (const queued of waiting)
|
|
1239
|
-
queued.cancelCapture?.();
|
|
1240
|
-
this.failPlainReseed(sub);
|
|
1241
|
-
for (const queued of waiting)
|
|
1242
|
-
this.failPlainReseed(queued);
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
// Admit the next recipe before publishing this potentially large seed.
|
|
1246
|
-
this.drainPlainReseeds();
|
|
1247
|
-
result.publish();
|
|
1248
|
-
if (resumeReason && !sub.closed && !sub.frozen)
|
|
1249
|
-
sub.onEvent({ type: "flow", state: "resumed", reason: resumeReason });
|
|
1250
|
-
}, true);
|
|
988
|
+
this.beginRecovery(sub.pane, resumeReason ?? "requested");
|
|
1251
989
|
}
|
|
1252
990
|
retireInternalReadMarker(runtime, marker) {
|
|
1253
991
|
if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
|
|
1254
992
|
throw new TypeError("internal read cleanup requires a runtime pane id");
|
|
1255
993
|
retireInternalReadOperation(marker, runtime);
|
|
994
|
+
this.clearInternalReadMarkerOption(runtime, marker);
|
|
995
|
+
}
|
|
996
|
+
clearInternalReadMarkerOption(runtime, marker) {
|
|
997
|
+
if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
|
|
998
|
+
throw new TypeError("internal read cleanup requires a runtime pane id");
|
|
999
|
+
// Success must keep the in-memory proof redeemable by a delayed observer.
|
|
1000
|
+
// The server option must still be removed when no observer hook is installed.
|
|
1256
1001
|
// Pane capture phases overlap under cancellation. Clear only the exact
|
|
1257
|
-
//
|
|
1002
|
+
// owned marker so a late A callback cannot erase the newer B authority.
|
|
1258
1003
|
// Both selected branches must emit one reply in addition to if-shell's
|
|
1259
1004
|
// own reply. An empty false branch emits none and shifts the control FIFO
|
|
1260
1005
|
// whenever the true branch runs during capture cancellation.
|
|
@@ -1301,6 +1046,10 @@ export class SessionChannel {
|
|
|
1301
1046
|
return;
|
|
1302
1047
|
sub.frozen = true;
|
|
1303
1048
|
sub.cancelCapture?.();
|
|
1049
|
+
const activeRecovery = this.recoveries.get(sub.pane.runtimeId);
|
|
1050
|
+
if (activeRecovery &&
|
|
1051
|
+
[...sub.pane.subs].some((candidate) => !candidate.closed && !candidate.frozen))
|
|
1052
|
+
this.retrySnapshot(activeRecovery);
|
|
1304
1053
|
sub.onEvent({ type: "flow", state: "paused", reason: "requested" });
|
|
1305
1054
|
const pane = sub.pane;
|
|
1306
1055
|
const allFrozen = [...pane.subs].every((candidate) => candidate.frozen || candidate.closed);
|
|
@@ -1376,40 +1125,49 @@ export class SessionChannel {
|
|
|
1376
1125
|
if (!recovery)
|
|
1377
1126
|
return;
|
|
1378
1127
|
recovery.retired = true;
|
|
1379
|
-
|
|
1128
|
+
this.recoveries.delete(runtime);
|
|
1129
|
+
this.snapshotQueue.delete(runtime);
|
|
1380
1130
|
recovery.cancelCommandDeadline?.();
|
|
1381
1131
|
recovery.cancelNoProgressDeadline?.();
|
|
1382
1132
|
recovery.cancelAbsoluteDeadline?.();
|
|
1383
|
-
if (recovery.atomicCollectorNonce)
|
|
1384
|
-
this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
|
|
1385
|
-
recovery.atomicCollectorNonce = null;
|
|
1386
|
-
this.recoveries.delete(runtime);
|
|
1387
|
-
this.retireContinueNotificationOwner(recovery);
|
|
1388
1133
|
const pane = this.panesByRuntime.get(runtime);
|
|
1389
1134
|
if (pane?.incarnation === recovery.paneIncarnation)
|
|
1390
1135
|
for (const sub of pane.subs)
|
|
1391
1136
|
sub.feed.abortCurrent();
|
|
1137
|
+
if (recovery.lease)
|
|
1138
|
+
this.retireSnapshotLease(recovery.lease);
|
|
1139
|
+
else if (recovery.atomicCollectorNonce)
|
|
1140
|
+
this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
|
|
1141
|
+
recovery.atomicCollectorNonce = null;
|
|
1392
1142
|
}
|
|
1393
1143
|
beginRecovery(pane, reason) {
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1144
|
+
if (this.disposed)
|
|
1145
|
+
return;
|
|
1146
|
+
const current = this.recoveries.get(pane.runtimeId);
|
|
1147
|
+
if (current && !current.retired) {
|
|
1148
|
+
if (this.snapshotQueue.get(pane.runtimeId) === current)
|
|
1149
|
+
return;
|
|
1150
|
+
this.retrySnapshot(current);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (this.snapshotQueue.size >= MAX_QUEUED_SNAPSHOTS) {
|
|
1154
|
+
for (const sub of pane.subs) {
|
|
1155
|
+
sub.feed.abortCurrent();
|
|
1156
|
+
if (!sub.closed && !sub.frozen)
|
|
1157
|
+
sub.onEvent({ type: "fault", reason: "native-recovery-failed" });
|
|
1158
|
+
}
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1398
1161
|
const recovery = {
|
|
1399
1162
|
ordinal: ++this.recoveryOrdinal,
|
|
1400
|
-
runtimeId:
|
|
1163
|
+
runtimeId: pane.runtimeId,
|
|
1401
1164
|
paneIncarnation: pane.incarnation,
|
|
1402
1165
|
reason,
|
|
1403
1166
|
startedAtMs: this.recoveryNowMs(),
|
|
1404
1167
|
retired: false,
|
|
1405
|
-
|
|
1406
|
-
continueNotify: false,
|
|
1407
|
-
stage: "continue",
|
|
1168
|
+
stage: "queued",
|
|
1408
1169
|
attempts: 0,
|
|
1409
1170
|
reseedOrdinal: 0,
|
|
1410
|
-
outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
|
|
1411
|
-
candidateFingerprint: null,
|
|
1412
|
-
confirmationFingerprint: null,
|
|
1413
1171
|
confirmationOrdinal: 0,
|
|
1414
1172
|
atomicCollectorNonce: null,
|
|
1415
1173
|
collectorStarted: false,
|
|
@@ -1420,103 +1178,349 @@ export class SessionChannel {
|
|
|
1420
1178
|
collectorStatusObserved: false,
|
|
1421
1179
|
collectorObserverEmissionObserved: false,
|
|
1422
1180
|
collectorFailureReason: null,
|
|
1423
|
-
cancelQuiet: null,
|
|
1424
1181
|
cancelCommandDeadline: null,
|
|
1425
1182
|
cancelNoProgressDeadline: null,
|
|
1426
1183
|
cancelAbsoluteDeadline: null,
|
|
1427
1184
|
};
|
|
1428
|
-
this.recoveries.set(
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1185
|
+
this.recoveries.set(pane.runtimeId, recovery);
|
|
1186
|
+
this.snapshotQueue.set(pane.runtimeId, recovery);
|
|
1187
|
+
this.drainSnapshots();
|
|
1188
|
+
}
|
|
1189
|
+
beginLocalOverflowRecovery(pane) {
|
|
1190
|
+
this.beginRecovery(pane, "backpressure");
|
|
1191
|
+
}
|
|
1192
|
+
snapshotContext(pane) {
|
|
1193
|
+
return {
|
|
1194
|
+
paneId: pane.runtimeId,
|
|
1195
|
+
incarnation: pane.incarnation,
|
|
1196
|
+
layoutGeneration: pane.snapshotLayoutGeneration,
|
|
1197
|
+
participants: [...pane.subs].filter((sub) => !sub.closed && !sub.frozen),
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
snapshotLeaseCurrent(lease) {
|
|
1201
|
+
return (this.snapshotActive === lease && !lease.retired && this.recoveryPane(lease.recovery) !== null);
|
|
1202
|
+
}
|
|
1203
|
+
drainSnapshots() {
|
|
1204
|
+
if (this.snapshotActive || this.disposed)
|
|
1205
|
+
return;
|
|
1206
|
+
for (const [runtime, recovery] of this.snapshotQueue) {
|
|
1207
|
+
if (recovery.waitForSyncAfter !== undefined &&
|
|
1208
|
+
this.completedSyncOrdinal <= recovery.waitForSyncAfter)
|
|
1209
|
+
continue;
|
|
1210
|
+
this.snapshotQueue.delete(runtime);
|
|
1211
|
+
const pane = this.recoveryPane(recovery);
|
|
1212
|
+
if (!pane || ![...pane.subs].some((sub) => !sub.closed && !sub.frozen)) {
|
|
1213
|
+
this.cancelRecovery(runtime);
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
1217
|
+
this.failRecovery(recovery, "attempts-exhausted");
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const lease = {
|
|
1221
|
+
recovery,
|
|
1222
|
+
retired: false,
|
|
1223
|
+
wireNonce: null,
|
|
1224
|
+
retry: false,
|
|
1225
|
+
fencePending: false,
|
|
1226
|
+
cancelFence: null,
|
|
1227
|
+
};
|
|
1228
|
+
this.snapshotActive = lease;
|
|
1229
|
+
recovery.lease = lease;
|
|
1230
|
+
if (!recovery.cancelAbsoluteDeadline) {
|
|
1231
|
+
recovery.startedAtMs = this.recoveryNowMs();
|
|
1232
|
+
this.beginRecoveryConvergence(recovery);
|
|
1233
|
+
}
|
|
1234
|
+
else
|
|
1235
|
+
this.noteRecoveryProgress(recovery);
|
|
1236
|
+
for (const sub of pane.subs)
|
|
1237
|
+
if (!sub.closed && !sub.frozen)
|
|
1238
|
+
sub.feed.abortCurrent();
|
|
1239
|
+
const target = this.nativeRecoveryTarget(pane);
|
|
1240
|
+
if (recovery.nativeOwner &&
|
|
1241
|
+
(target?.serverEpoch !== recovery.nativeOwner.serverEpoch ||
|
|
1242
|
+
target?.paneBirthId !== recovery.nativeOwner.paneBirthId)) {
|
|
1243
|
+
this.failRecovery(recovery, "command-error");
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (target) {
|
|
1247
|
+
recovery.nativeOwner ??= target;
|
|
1248
|
+
recovery.stock = undefined;
|
|
1249
|
+
this.recoverNativeAtomic(pane, recovery, target);
|
|
1250
|
+
}
|
|
1251
|
+
else
|
|
1252
|
+
this.startStockSnapshot(pane, recovery, lease);
|
|
1253
|
+
return;
|
|
1432
1254
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
if (
|
|
1436
|
-
|
|
1437
|
-
|
|
1255
|
+
}
|
|
1256
|
+
releaseSnapshotLease(lease) {
|
|
1257
|
+
if (this.snapshotActive !== lease || lease.wireNonce || lease.fencePending)
|
|
1258
|
+
return;
|
|
1259
|
+
lease.cancelFence?.();
|
|
1260
|
+
lease.cancelFence = null;
|
|
1261
|
+
const pane = this.panesByRuntime.get(lease.recovery.runtimeId);
|
|
1262
|
+
if (lease.ownsPause &&
|
|
1263
|
+
pane?.incarnation === lease.recovery.paneIncarnation &&
|
|
1264
|
+
pane.subs.size === 0 &&
|
|
1265
|
+
!this.ledger.isRequested(pane.runtimeId)) {
|
|
1266
|
+
this.io.commandInline(`refresh-client -A '${pane.runtimeId}:continue'`, () => { });
|
|
1267
|
+
this.ledger.noteContinued(pane.runtimeId);
|
|
1268
|
+
}
|
|
1269
|
+
this.snapshotActive = null;
|
|
1270
|
+
if (lease.recovery.lease === lease)
|
|
1271
|
+
lease.recovery.lease = undefined;
|
|
1272
|
+
if (lease.retry && this.recoveryPane(lease.recovery)) {
|
|
1273
|
+
lease.recovery.stage = "queued";
|
|
1274
|
+
this.snapshotQueue.set(lease.recovery.runtimeId, lease.recovery);
|
|
1275
|
+
}
|
|
1276
|
+
this.drainSnapshots();
|
|
1277
|
+
}
|
|
1278
|
+
retireSnapshotLease(lease, retry = false) {
|
|
1279
|
+
if (this.snapshotActive !== lease)
|
|
1280
|
+
return;
|
|
1281
|
+
lease.retired = true;
|
|
1282
|
+
if (this.disposed) {
|
|
1283
|
+
lease.cancelFence?.();
|
|
1284
|
+
lease.cancelFence = null;
|
|
1285
|
+
this.snapshotActive = null;
|
|
1286
|
+
this.snapshotQueue.clear();
|
|
1438
1287
|
return;
|
|
1439
1288
|
}
|
|
1440
|
-
|
|
1441
|
-
recovery.
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
if (
|
|
1447
|
-
this.
|
|
1289
|
+
lease.retry ||= retry;
|
|
1290
|
+
lease.recovery.stock?.invalidate();
|
|
1291
|
+
lease.cleanup?.();
|
|
1292
|
+
lease.cleanup = undefined;
|
|
1293
|
+
lease.recovery.cancelCommandDeadline?.();
|
|
1294
|
+
lease.recovery.cancelCommandDeadline = null;
|
|
1295
|
+
if (lease.wireNonce) {
|
|
1296
|
+
this.io.retireAtomicPaneSnapshotCollector?.(lease.wireNonce, "retired");
|
|
1448
1297
|
return;
|
|
1449
1298
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1299
|
+
if (lease.fencePending)
|
|
1300
|
+
return;
|
|
1301
|
+
// Native wrapper/setup commands still own ordinary FIFO slots. A separate
|
|
1302
|
+
// bounded command proves they drained before another raw hook is admitted.
|
|
1303
|
+
lease.fencePending = true;
|
|
1304
|
+
const token = `tmux-ide-snapshot-admission:${randomBytes(24).toString("hex")}`;
|
|
1305
|
+
const failed = () => {
|
|
1306
|
+
if (this.snapshotActive !== lease)
|
|
1458
1307
|
return;
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1308
|
+
lease.retry = false;
|
|
1309
|
+
lease.cancelFence?.();
|
|
1310
|
+
void this.io.dispose();
|
|
1311
|
+
};
|
|
1312
|
+
lease.cancelFence = this.scheduleRecovery(failed, RECOVERY_ABSOLUTE_DEADLINE_MS);
|
|
1313
|
+
const done = (reply) => {
|
|
1314
|
+
if (this.snapshotActive !== lease)
|
|
1315
|
+
return;
|
|
1316
|
+
if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== token) {
|
|
1317
|
+
failed();
|
|
1463
1318
|
return;
|
|
1464
1319
|
}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
this.
|
|
1470
|
-
|
|
1471
|
-
|
|
1320
|
+
lease.fencePending = false;
|
|
1321
|
+
this.releaseSnapshotLease(lease);
|
|
1322
|
+
};
|
|
1323
|
+
if (this.io.commandBoundedInline)
|
|
1324
|
+
this.io.commandBoundedInline(`display-message -p -l ${token}`, { maxBytes: 256, maxLines: 1 }, done);
|
|
1325
|
+
else
|
|
1326
|
+
this.io.commandInline(`display-message -p -l ${token}`, done);
|
|
1472
1327
|
}
|
|
1473
|
-
|
|
1328
|
+
awaitSnapshotLayout(recovery) {
|
|
1329
|
+
recovery.cancelNoProgressDeadline?.();
|
|
1330
|
+
recovery.cancelNoProgressDeadline = null;
|
|
1331
|
+
recovery.waitForSyncAfter = this.syncOrdinal;
|
|
1332
|
+
this.scheduleSync();
|
|
1333
|
+
}
|
|
1334
|
+
retrySnapshot(recovery) {
|
|
1335
|
+
const pane = this.recoveryPane(recovery);
|
|
1336
|
+
if (!pane)
|
|
1337
|
+
return;
|
|
1474
1338
|
for (const sub of pane.subs)
|
|
1475
|
-
sub.
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
stage
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1339
|
+
if (!sub.closed && !sub.frozen)
|
|
1340
|
+
sub.feed.abortCurrent();
|
|
1341
|
+
recovery.stock?.invalidate();
|
|
1342
|
+
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS ||
|
|
1343
|
+
(recovery.cancelAbsoluteDeadline &&
|
|
1344
|
+
this.recoveryNowMs() - recovery.startedAtMs >= RECOVERY_ABSOLUTE_DEADLINE_MS)) {
|
|
1345
|
+
this.failRecovery(recovery, "attempts-exhausted");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (recovery.lease)
|
|
1349
|
+
this.retireSnapshotLease(recovery.lease, true);
|
|
1350
|
+
else {
|
|
1351
|
+
recovery.stage = "queued";
|
|
1352
|
+
this.snapshotQueue.set(recovery.runtimeId, recovery);
|
|
1353
|
+
this.drainSnapshots();
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
startStockSnapshot(pane, recovery, lease) {
|
|
1357
|
+
if (!this.io.armAtomicPaneSnapshotCollector || !this.io.retireAtomicPaneSnapshotCollector) {
|
|
1358
|
+
this.failRecovery(recovery, "command-error");
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (!this.nativeBootstrapConfirmed &&
|
|
1362
|
+
!this.nativeBootstrapUnavailable &&
|
|
1363
|
+
[...pane.subs].some((sub) => !sub.closed && !sub.frozen && sub.nativeBootstrap)) {
|
|
1364
|
+
// Negotiate the live server before placing -R in a NOHOOKS transaction:
|
|
1365
|
+
// an unsupported flag aborts that hook before its completion sentinel.
|
|
1366
|
+
recovery.nativeProbeAttempts = (recovery.nativeProbeAttempts ?? 0) + 1;
|
|
1367
|
+
let marker = null;
|
|
1368
|
+
let probeSettled = false;
|
|
1369
|
+
recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
|
|
1370
|
+
if (probeSettled || !this.snapshotLeaseCurrent(lease))
|
|
1371
|
+
return;
|
|
1372
|
+
probeSettled = true;
|
|
1373
|
+
if ((recovery.nativeProbeAttempts ?? 0) < 2)
|
|
1374
|
+
this.retrySnapshot(recovery);
|
|
1375
|
+
else
|
|
1376
|
+
this.failRecovery(recovery, "command-timeout");
|
|
1377
|
+
}, RECOVERY_COMMAND_DEADLINE_MS);
|
|
1378
|
+
this.captureWithViewer(pane.runtimeId, ["-R", "-S", "-"], (value) => {
|
|
1379
|
+
marker = value;
|
|
1380
|
+
}, (reply) => {
|
|
1381
|
+
if (marker)
|
|
1382
|
+
this.retireInternalReadMarker(pane.runtimeId, marker);
|
|
1383
|
+
if (probeSettled || !this.snapshotLeaseCurrent(lease))
|
|
1384
|
+
return;
|
|
1385
|
+
probeSettled = true;
|
|
1386
|
+
recovery.cancelCommandDeadline?.();
|
|
1387
|
+
recovery.cancelCommandDeadline = null;
|
|
1388
|
+
const native = reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
|
|
1389
|
+
if (native && isNativeBootstrapCapture(native))
|
|
1390
|
+
this.nativeBootstrapConfirmed = true;
|
|
1391
|
+
else if (nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
|
|
1392
|
+
this.nativeBootstrapUnavailable = true;
|
|
1393
|
+
for (const current of this.panesByRuntime.values())
|
|
1394
|
+
for (const sub of current.subs)
|
|
1395
|
+
sub.nativeBootstrap = false;
|
|
1396
|
+
}
|
|
1397
|
+
else {
|
|
1398
|
+
if ((recovery.nativeProbeAttempts ?? 0) < 2)
|
|
1399
|
+
this.retrySnapshot(recovery);
|
|
1400
|
+
else
|
|
1401
|
+
this.failRecovery(recovery, "command-error");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
this.startStockSnapshot(pane, recovery, lease);
|
|
1405
|
+
}, { maxBytes: RECOVERY_CAPTURE_MAX_BYTES, maxLines: RECOVERY_CAPTURE_MAX_LINES });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
recovery.attempts += 1;
|
|
1409
|
+
recovery.stage = "stock-pause";
|
|
1410
|
+
recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
|
|
1411
|
+
// These rows were received before this pause and belong to the new seed,
|
|
1412
|
+
// never to a delayed old-geometry replay after it.
|
|
1413
|
+
const pending = pane.windowRuntimeId
|
|
1414
|
+
? this.pendingLayoutOutput.get(pane.windowRuntimeId)
|
|
1415
|
+
: null;
|
|
1416
|
+
if (pending) {
|
|
1417
|
+
pending.records = pending.records.filter((record) => record.pane !== pane.runtimeId);
|
|
1418
|
+
pending.bytes = pending.records.reduce((bytes, record) => bytes + record.data.byteLength, 0);
|
|
1512
1419
|
}
|
|
1513
1420
|
this.observeRecovery(pane, recovery, "pause");
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1421
|
+
const nonce = randomBytes(24).toString("hex");
|
|
1422
|
+
const hook = `@tmux_ide_pause_${nonce}`;
|
|
1423
|
+
const body = `display-message -p -l '%tmux-ide-atomic-v1 ${nonce} start'` +
|
|
1424
|
+
` ; refresh-client -A '${pane.runtimeId}:pause'` +
|
|
1425
|
+
` ; display-message -p -l '%tmux-ide-atomic-v1 ${nonce} complete'`;
|
|
1426
|
+
let settled = false;
|
|
1427
|
+
let accepted = false;
|
|
1428
|
+
const cleanup = () => this.io.commandListInline(`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
|
|
1429
|
+
`${tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${hook}`)} ` +
|
|
1430
|
+
`${tmuxSingleQuote("display-message -p -l pause-cleanup-skipped")}`, 2, 1, () => { });
|
|
1431
|
+
lease.cleanup = cleanup;
|
|
1432
|
+
const failed = () => {
|
|
1433
|
+
if (settled)
|
|
1434
|
+
return;
|
|
1435
|
+
settled = true;
|
|
1436
|
+
cleanup();
|
|
1437
|
+
if (this.snapshotLeaseCurrent(lease))
|
|
1438
|
+
this.retrySnapshot(recovery);
|
|
1439
|
+
};
|
|
1440
|
+
recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
|
|
1441
|
+
this.input.flush();
|
|
1442
|
+
this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${hook} ${tmuxSingleQuote(body)}`, (reply) => {
|
|
1443
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
1444
|
+
cleanup();
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (!reply.ok) {
|
|
1448
|
+
failed();
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
lease.wireNonce = nonce;
|
|
1452
|
+
const armed = this.io.armAtomicPaneSnapshotCollector({
|
|
1453
|
+
nonce,
|
|
1454
|
+
kind: "pause",
|
|
1455
|
+
runtimePaneId: pane.runtimeId,
|
|
1456
|
+
maxCaptureBytes: 1024,
|
|
1457
|
+
maxCaptureLines: 16,
|
|
1458
|
+
maxCursorBytes: 128,
|
|
1459
|
+
observerCommandCount: 0,
|
|
1460
|
+
onSettled: (result) => {
|
|
1461
|
+
cleanup();
|
|
1462
|
+
if (settled || !this.snapshotLeaseCurrent(lease))
|
|
1463
|
+
return;
|
|
1464
|
+
if (!result.ok) {
|
|
1465
|
+
failed();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
// The authenticated NOHOOKS body targets the same live pane as
|
|
1469
|
+
// set-hook -Rp. A successful pause is a no-op only when already
|
|
1470
|
+
// paused; its complete guard therefore fences that case too.
|
|
1471
|
+
accepted = recovery.stock.observePause(this.snapshotContext(pane));
|
|
1472
|
+
if (!accepted) {
|
|
1473
|
+
failed();
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
lease.ownsPause = true;
|
|
1477
|
+
this.observedPaused.add(pane.runtimeId);
|
|
1478
|
+
this.ledger.notePause(pane.runtimeId);
|
|
1479
|
+
settled = true;
|
|
1480
|
+
recovery.cancelCommandDeadline?.();
|
|
1481
|
+
recovery.cancelCommandDeadline = null;
|
|
1482
|
+
},
|
|
1483
|
+
onDrained: (reason) => {
|
|
1484
|
+
if (lease.wireNonce === nonce)
|
|
1485
|
+
lease.wireNonce = null;
|
|
1486
|
+
if (reason === "channel-exit") {
|
|
1487
|
+
lease.retry = false;
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (!this.snapshotLeaseCurrent(lease) || !accepted) {
|
|
1491
|
+
this.releaseSnapshotLease(lease);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
recovery.stage = "stock-capture";
|
|
1495
|
+
this.reseedRecoverySubscribersAtomic(pane, recovery, (result) => {
|
|
1496
|
+
if (!this.snapshotLeaseCurrent(lease))
|
|
1497
|
+
return;
|
|
1498
|
+
if (!result.ok || !result.publish())
|
|
1499
|
+
this.retrySnapshot(recovery);
|
|
1500
|
+
else
|
|
1501
|
+
this.convergeRecovery(pane, recovery);
|
|
1502
|
+
}, true);
|
|
1503
|
+
},
|
|
1504
|
+
}, Math.max(1, Math.floor(RECOVERY_ABSOLUTE_DEADLINE_MS - (this.recoveryNowMs() - recovery.startedAtMs))));
|
|
1505
|
+
if (!armed) {
|
|
1506
|
+
lease.wireNonce = null;
|
|
1507
|
+
failed();
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// Once queued, this pause may execute even if its local callback is
|
|
1511
|
+
// cancelled. Return the owned pause after drain when no viewer remains.
|
|
1512
|
+
lease.ownsPause = true;
|
|
1513
|
+
// The option value is immutable for this attempt; compare it before
|
|
1514
|
+
// dispatch so another actor cannot replace the owned NOHOOKS body.
|
|
1515
|
+
this.io.commandListInline(`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
|
|
1516
|
+
`${tmuxSingleQuote(`set-hook -Rp -t ${pane.runtimeId} ${hook}`)} ` +
|
|
1517
|
+
`${tmuxSingleQuote("display-message -p -l pause-hook-rejected")}`, 2, 1, (result) => {
|
|
1518
|
+
if (settled || !this.snapshotLeaseCurrent(lease))
|
|
1519
|
+
return;
|
|
1520
|
+
if (!result.ok || result.lines.length)
|
|
1521
|
+
failed();
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1520
1524
|
}
|
|
1521
1525
|
nativeRecoveryTarget(pane) {
|
|
1522
1526
|
// Mixed/plain recovery requires an explicitly negotiated dual snapshot.
|
|
@@ -1536,7 +1540,8 @@ export class SessionChannel {
|
|
|
1536
1540
|
: null;
|
|
1537
1541
|
}
|
|
1538
1542
|
recoverNativeAtomic(pane, recovery, target) {
|
|
1539
|
-
|
|
1543
|
+
const lease = recovery.lease;
|
|
1544
|
+
if (!lease || !this.snapshotLeaseCurrent(lease) || this.recoveryPane(recovery) !== pane)
|
|
1540
1545
|
return;
|
|
1541
1546
|
const currentTarget = this.nativeRecoveryTarget(pane);
|
|
1542
1547
|
if (currentTarget?.serverEpoch !== target.serverEpoch ||
|
|
@@ -1558,6 +1563,7 @@ export class SessionChannel {
|
|
|
1558
1563
|
const current = this.nativeRecoveryTarget(pane);
|
|
1559
1564
|
const live = [...pane.subs].filter((sub) => !sub.closed && !sub.frozen);
|
|
1560
1565
|
return (this.recoveryPane(recovery) === pane &&
|
|
1566
|
+
this.snapshotLeaseCurrent(lease) &&
|
|
1561
1567
|
recovery.reseedOrdinal === ordinal &&
|
|
1562
1568
|
current?.serverEpoch === target.serverEpoch &&
|
|
1563
1569
|
current.paneBirthId === target.paneBirthId &&
|
|
@@ -1576,11 +1582,12 @@ export class SessionChannel {
|
|
|
1576
1582
|
sub.feed.abortCurrent();
|
|
1577
1583
|
// A malformed/late reply may follow a committed resume. Every retry
|
|
1578
1584
|
// explicitly pauses first; never fall through to stock capture.
|
|
1579
|
-
this.
|
|
1585
|
+
this.retrySnapshot(recovery);
|
|
1580
1586
|
};
|
|
1581
1587
|
recovery.stage = "native-pause";
|
|
1582
1588
|
recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
|
|
1583
1589
|
this.input.flush();
|
|
1590
|
+
lease.ownsPause = true;
|
|
1584
1591
|
this.io.commandInline(`refresh-client -A '${pane.runtimeId}:pause'`, (pauseReply) => {
|
|
1585
1592
|
if (settled || !exact()) {
|
|
1586
1593
|
failed();
|
|
@@ -1620,12 +1627,23 @@ export class SessionChannel {
|
|
|
1620
1627
|
failed();
|
|
1621
1628
|
return;
|
|
1622
1629
|
}
|
|
1630
|
+
const probe = parseCursorProbe(result.cursorLine);
|
|
1631
|
+
const size = this.layoutCaptureSizeFor(participants[0].sub);
|
|
1632
|
+
if (!probe ||
|
|
1633
|
+
probe.y >= probe.rows ||
|
|
1634
|
+
result.capture.cols !== probe.cols ||
|
|
1635
|
+
result.capture.rows !== probe.rows) {
|
|
1636
|
+
failed();
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if ((pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId)) ||
|
|
1640
|
+
(size && (size.cols !== probe.cols || size.rows !== probe.rows))) {
|
|
1641
|
+
this.awaitSnapshotLayout(recovery);
|
|
1642
|
+
failed();
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1623
1645
|
this.nativeBootstrapConfirmed = true;
|
|
1624
|
-
this.
|
|
1625
|
-
// The selected child reply owns its inline %continue. It adds no
|
|
1626
|
-
// asynchronous notification debt to the legacy continue queue.
|
|
1627
|
-
recovery.continueReply = true;
|
|
1628
|
-
recovery.continueNotify = true;
|
|
1646
|
+
this.observeSnapshotMetadata(pane, result.cursorLine);
|
|
1629
1647
|
const ansiLines = result.ansiCapture
|
|
1630
1648
|
? captureLinesFromAnsiBytes(result.ansiCapture)
|
|
1631
1649
|
: null;
|
|
@@ -1681,10 +1699,13 @@ export class SessionChannel {
|
|
|
1681
1699
|
if (!this.recoveryPane(recovery) || !recovery.cancelAbsoluteDeadline)
|
|
1682
1700
|
return;
|
|
1683
1701
|
recovery.cancelNoProgressDeadline?.();
|
|
1684
|
-
|
|
1702
|
+
const cancel = this.scheduleRecovery(() => {
|
|
1703
|
+
if (recovery.cancelNoProgressDeadline !== cancel)
|
|
1704
|
+
return;
|
|
1685
1705
|
if (this.recoveryPane(recovery))
|
|
1686
1706
|
this.failRecovery(recovery, "no-progress");
|
|
1687
1707
|
}, RECOVERY_NO_PROGRESS_DEADLINE_MS);
|
|
1708
|
+
recovery.cancelNoProgressDeadline = cancel;
|
|
1688
1709
|
}
|
|
1689
1710
|
noteAtomicCollectorProgress(recovery, nonce, progress) {
|
|
1690
1711
|
if (this.recoveryPane(recovery) === null ||
|
|
@@ -1700,150 +1721,21 @@ export class SessionChannel {
|
|
|
1700
1721
|
recovery.collectorObserverEmissionObserved ||= progress.observerEmissionObserved;
|
|
1701
1722
|
this.noteRecoveryProgress(recovery);
|
|
1702
1723
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
}
|
|
1708
|
-
const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
1709
|
-
if (live.length === 0) {
|
|
1724
|
+
reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish) {
|
|
1725
|
+
const lease = recovery.lease;
|
|
1726
|
+
const stock = recovery.stock;
|
|
1727
|
+
if (!lease || !stock || !this.snapshotLeaseCurrent(lease)) {
|
|
1710
1728
|
done(FAILED_RESEED_RESULT);
|
|
1711
1729
|
return;
|
|
1712
1730
|
}
|
|
1713
|
-
const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
|
|
1714
|
-
const nativeCapture = !this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
|
|
1715
|
-
const reseedOrdinal = ++recovery.reseedOrdinal;
|
|
1716
|
-
let settled = false;
|
|
1717
|
-
let captureSucceeded = false;
|
|
1718
|
-
let markerRetired = false;
|
|
1719
|
-
let captureLines = null;
|
|
1720
|
-
// One pane authority capture is enough for every subscriber. Per-feed
|
|
1721
|
-
// epochs still independently fence delivery, while membership is frozen
|
|
1722
|
-
// across both FIFO replies so no subscriber can join half a snapshot.
|
|
1723
|
-
this.input.flush();
|
|
1724
|
-
const history = this.opts.historyLines ?? "";
|
|
1725
|
-
let internalReadMarker = null;
|
|
1726
|
-
const participantsExact = () => {
|
|
1727
|
-
if (this.recoveryPane(recovery) !== pane ||
|
|
1728
|
-
recovery.reseedOrdinal !== reseedOrdinal ||
|
|
1729
|
-
participants.some(({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub)))
|
|
1730
|
-
return false;
|
|
1731
|
-
const current = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
1732
|
-
return (current.length === participants.length &&
|
|
1733
|
-
current.every((sub) => participants.some((participant) => participant.sub === sub)));
|
|
1734
|
-
};
|
|
1735
|
-
const retireMarker = () => {
|
|
1736
|
-
if (markerRetired)
|
|
1737
|
-
return;
|
|
1738
|
-
markerRetired = true;
|
|
1739
|
-
if (internalReadMarker)
|
|
1740
|
-
this.retireInternalReadMarker(pane.runtimeId, internalReadMarker);
|
|
1741
|
-
};
|
|
1742
|
-
const fail = () => {
|
|
1743
|
-
if (settled)
|
|
1744
|
-
return;
|
|
1745
|
-
settled = true;
|
|
1746
|
-
if (!captureSucceeded)
|
|
1747
|
-
retireMarker();
|
|
1748
|
-
for (const { sub } of participants)
|
|
1749
|
-
sub.feed.abortCurrent();
|
|
1750
|
-
done(FAILED_RESEED_RESULT);
|
|
1751
|
-
};
|
|
1752
|
-
this.captureWithViewer(pane.runtimeId, [...(nativeCapture ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`], (marker) => {
|
|
1753
|
-
internalReadMarker = marker;
|
|
1754
|
-
}, (reply) => {
|
|
1755
|
-
if (!reply.ok) {
|
|
1756
|
-
if (nativeCapture && nativeBootstrapUnsupported(false, reply.lines, null)) {
|
|
1757
|
-
this.nativeBootstrapUnavailable = true;
|
|
1758
|
-
for (const { sub } of participants)
|
|
1759
|
-
sub.nativeBootstrap = false;
|
|
1760
|
-
}
|
|
1761
|
-
fail();
|
|
1762
|
-
return;
|
|
1763
|
-
}
|
|
1764
|
-
captureSucceeded = true;
|
|
1765
|
-
if (!participantsExact()) {
|
|
1766
|
-
fail();
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
captureLines = Object.freeze([...reply.lines]);
|
|
1770
|
-
const native = nativeCapture ? decodeNativeGridCapture(captureLines.join("\n")) : null;
|
|
1771
|
-
if (nativeCapture && (!native || !isNativeBootstrapCapture(native))) {
|
|
1772
|
-
if (nativeBootstrapUnsupported(true, captureLines, native)) {
|
|
1773
|
-
this.nativeBootstrapUnavailable = true;
|
|
1774
|
-
for (const { sub } of participants)
|
|
1775
|
-
sub.nativeBootstrap = false;
|
|
1776
|
-
}
|
|
1777
|
-
fail();
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
if (native)
|
|
1781
|
-
this.nativeBootstrapConfirmed = true;
|
|
1782
|
-
for (const { sub, epoch } of participants) {
|
|
1783
|
-
if (native)
|
|
1784
|
-
sub.feed.captureNativeReply(epoch, native);
|
|
1785
|
-
else
|
|
1786
|
-
sub.feed.captureReply(epoch, captureLines);
|
|
1787
|
-
}
|
|
1788
|
-
});
|
|
1789
|
-
this.io.commandInline(`display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"`, (reply) => {
|
|
1790
|
-
if (settled)
|
|
1791
|
-
return;
|
|
1792
|
-
if (!participantsExact() || captureLines === null || !reply.ok) {
|
|
1793
|
-
fail();
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
const cursorLine = reply.lines[0] ?? "";
|
|
1797
|
-
const fallbackSize = this.layoutSizeFor(pane.runtimeId);
|
|
1798
|
-
this.observeScrollOnClear(pane, cursorLine);
|
|
1799
|
-
const deliveries = participants.map(({ sub, epoch }) => ({
|
|
1800
|
-
sub,
|
|
1801
|
-
epoch,
|
|
1802
|
-
events: sub.feed
|
|
1803
|
-
.cursorReply(epoch, cursorLine, fallbackSize)
|
|
1804
|
-
.map((event) => event.type === "seed" && sub.nativeBootstrap && !nativeCapture
|
|
1805
|
-
? { ...event, requiresNativeRecapture: true }
|
|
1806
|
-
: event),
|
|
1807
|
-
}));
|
|
1808
|
-
if (!participantsExact() || deliveries.some(({ events }) => events.length === 0)) {
|
|
1809
|
-
fail();
|
|
1810
|
-
return;
|
|
1811
|
-
}
|
|
1812
|
-
let published = false;
|
|
1813
|
-
const publish = () => {
|
|
1814
|
-
if (published)
|
|
1815
|
-
return true;
|
|
1816
|
-
if (!participantsExact())
|
|
1817
|
-
return false;
|
|
1818
|
-
published = true;
|
|
1819
|
-
for (const { sub, events } of deliveries)
|
|
1820
|
-
for (const event of events)
|
|
1821
|
-
sub.onEvent(event);
|
|
1822
|
-
return participantsExact();
|
|
1823
|
-
};
|
|
1824
|
-
if (!deferPublish && !publish()) {
|
|
1825
|
-
fail();
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
for (const { sub, epoch } of deliveries)
|
|
1829
|
-
sub.feed.quarantine(epoch);
|
|
1830
|
-
settled = true;
|
|
1831
|
-
done({
|
|
1832
|
-
ok: true,
|
|
1833
|
-
fingerprint: snapshotFingerprint(captureLines, cursorLine, fallbackSize),
|
|
1834
|
-
publish,
|
|
1835
|
-
hold: () => { },
|
|
1836
|
-
});
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
reseedRecoverySubscribersAtomic(pane, recovery, done, deferPublish) {
|
|
1840
1731
|
const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
1841
1732
|
if (live.length === 0) {
|
|
1842
1733
|
done(FAILED_RESEED_RESULT);
|
|
1843
1734
|
return;
|
|
1844
1735
|
}
|
|
1845
1736
|
const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
|
|
1846
|
-
const nativeCapture = !this.nativeBootstrapUnavailable && participants.
|
|
1737
|
+
const nativeCapture = !this.nativeBootstrapUnavailable && participants.some(({ sub }) => sub.nativeBootstrap);
|
|
1738
|
+
const dualCapture = nativeCapture && participants.some(({ sub }) => !sub.nativeBootstrap);
|
|
1847
1739
|
const reseedOrdinal = ++recovery.reseedOrdinal;
|
|
1848
1740
|
const nonce = this.opts.generateAtomicHookNonce?.() ?? randomBytes(24).toString("hex");
|
|
1849
1741
|
if (!/^[0-9a-f]{32,128}$/u.test(nonce)) {
|
|
@@ -1867,6 +1759,7 @@ export class SessionChannel {
|
|
|
1867
1759
|
recovery.collectorFailureReason = null;
|
|
1868
1760
|
const participantsExact = () => {
|
|
1869
1761
|
if (this.recoveryPane(recovery) !== pane ||
|
|
1762
|
+
!this.snapshotLeaseCurrent(lease) ||
|
|
1870
1763
|
recovery.reseedOrdinal !== reseedOrdinal ||
|
|
1871
1764
|
participants.some(({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub)))
|
|
1872
1765
|
return false;
|
|
@@ -1889,6 +1782,7 @@ export class SessionChannel {
|
|
|
1889
1782
|
tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${ownerName}`) +
|
|
1890
1783
|
` ${tmuxSingleQuote(`display-message -p -t ${pane.runtimeId} tmux-ide-atomic-cleanup-owner-skip-v1:${nonce}`)}`, 2, 1, () => { });
|
|
1891
1784
|
};
|
|
1785
|
+
lease.cleanup = cleanupHook;
|
|
1892
1786
|
const fail = (statusObserved = false) => {
|
|
1893
1787
|
if (settled)
|
|
1894
1788
|
return;
|
|
@@ -1910,21 +1804,27 @@ export class SessionChannel {
|
|
|
1910
1804
|
fail();
|
|
1911
1805
|
return;
|
|
1912
1806
|
}
|
|
1807
|
+
const observerRequired = this.opts.internalReadHookEmission !== undefined;
|
|
1913
1808
|
const safeObserver = observer !== null &&
|
|
1914
1809
|
/^[A-Za-z0-9._-]{1,256}$/u.test(observer.bufferName) &&
|
|
1915
1810
|
/^[A-Za-z0-9._-]{1,256}$/u.test(observer.signalChannel) &&
|
|
1916
1811
|
/^[A-Za-z0-9%:._|-]{1,1024}$/u.test(observer.record);
|
|
1917
|
-
if (!safeObserver) {
|
|
1812
|
+
if (observerRequired && !safeObserver) {
|
|
1918
1813
|
fail();
|
|
1919
1814
|
return;
|
|
1920
1815
|
}
|
|
1921
1816
|
const sentinel = (kind) => `display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
|
|
1922
|
-
const observerCommands =
|
|
1923
|
-
` ;
|
|
1817
|
+
const observerCommands = safeObserver
|
|
1818
|
+
? ` ; ${boundedTmuxInteractionAppendCommand(observer.bufferName, observer.record)}` +
|
|
1819
|
+
` ; wait-for -S ${observer.signalChannel}`
|
|
1820
|
+
: "";
|
|
1924
1821
|
const body = `set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
|
|
1925
1822
|
` ; ${sentinel("start")}` +
|
|
1926
1823
|
` ; capture-pane -p ${nativeCapture ? "-R" : "-e -J"} -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId}` +
|
|
1927
1824
|
` ; ${sentinel("capture-end")}` +
|
|
1825
|
+
(dualCapture
|
|
1826
|
+
? ` ; capture-pane -p -e -J -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId} ; ${sentinel("ansi-capture-end")}`
|
|
1827
|
+
: "") +
|
|
1928
1828
|
` ; display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"` +
|
|
1929
1829
|
` ; ${sentinel("cursor-end")}` +
|
|
1930
1830
|
` ; refresh-client -A ${tmuxSingleQuote(`${pane.runtimeId}:continue`)}` +
|
|
@@ -1938,6 +1838,10 @@ export class SessionChannel {
|
|
|
1938
1838
|
` ; ${sentinel("complete")}`;
|
|
1939
1839
|
this.input.flush();
|
|
1940
1840
|
const invoke = (reply) => {
|
|
1841
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
1842
|
+
cleanupHook();
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1941
1845
|
if (!reply.ok || !participantsExact()) {
|
|
1942
1846
|
fail();
|
|
1943
1847
|
return;
|
|
@@ -1947,13 +1851,25 @@ export class SessionChannel {
|
|
|
1947
1851
|
fail();
|
|
1948
1852
|
return;
|
|
1949
1853
|
}
|
|
1854
|
+
lease.wireNonce = nonce;
|
|
1855
|
+
this.observedPaused.delete(pane.runtimeId);
|
|
1950
1856
|
const armed = this.io.armAtomicPaneSnapshotCollector({
|
|
1951
1857
|
nonce,
|
|
1952
1858
|
runtimePaneId: pane.runtimeId,
|
|
1953
1859
|
maxCaptureBytes: RECOVERY_CAPTURE_MAX_BYTES,
|
|
1954
1860
|
maxCaptureLines: RECOVERY_CAPTURE_MAX_LINES,
|
|
1955
1861
|
maxCursorBytes: RECOVERY_CURSOR_MAX_BYTES,
|
|
1956
|
-
observerCommandCount: 2,
|
|
1862
|
+
observerCommandCount: safeObserver ? 2 : 0,
|
|
1863
|
+
dualCapture,
|
|
1864
|
+
onDrained: (reason) => {
|
|
1865
|
+
if (lease.wireNonce === nonce)
|
|
1866
|
+
lease.wireNonce = null;
|
|
1867
|
+
if (reason === "channel-exit") {
|
|
1868
|
+
lease.retry = false;
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
this.releaseSnapshotLease(lease);
|
|
1872
|
+
},
|
|
1957
1873
|
onProgress: (progress) => this.noteAtomicCollectorProgress(recovery, nonce, progress),
|
|
1958
1874
|
onSettled: (result) => {
|
|
1959
1875
|
if (recovery.atomicCollectorNonce === nonce)
|
|
@@ -1985,16 +1901,35 @@ export class SessionChannel {
|
|
|
1985
1901
|
fail(result.statusObserved);
|
|
1986
1902
|
return;
|
|
1987
1903
|
}
|
|
1904
|
+
if (dualCapture && !result.ansiCaptureLines) {
|
|
1905
|
+
fail(result.statusObserved);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1988
1908
|
if (native)
|
|
1989
1909
|
this.nativeBootstrapConfirmed = true;
|
|
1990
1910
|
for (const { sub, epoch } of participants) {
|
|
1991
|
-
if (native)
|
|
1911
|
+
if (native && sub.nativeBootstrap)
|
|
1992
1912
|
sub.feed.captureNativeReply(epoch, native);
|
|
1993
1913
|
else
|
|
1994
|
-
sub.feed.captureReply(epoch, captureLines);
|
|
1914
|
+
sub.feed.captureReply(epoch, result.ansiCaptureLines ?? captureLines);
|
|
1915
|
+
}
|
|
1916
|
+
const probe = parseCursorProbe(result.cursorLine);
|
|
1917
|
+
const layoutSize = this.layoutCaptureSizeFor(participants[0].sub);
|
|
1918
|
+
const pendingLayout = pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId);
|
|
1919
|
+
if (!probe ||
|
|
1920
|
+
probe.y >= probe.rows ||
|
|
1921
|
+
(native && (native.cols !== probe.cols || native.rows !== probe.rows))) {
|
|
1922
|
+
fail(result.statusObserved);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (pendingLayout ||
|
|
1926
|
+
(layoutSize && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows))) {
|
|
1927
|
+
this.awaitSnapshotLayout(recovery);
|
|
1928
|
+
fail(result.statusObserved);
|
|
1929
|
+
return;
|
|
1995
1930
|
}
|
|
1996
1931
|
const fallbackSize = this.layoutSizeFor(pane.runtimeId);
|
|
1997
|
-
this.
|
|
1932
|
+
this.observeSnapshotMetadata(pane, result.cursorLine);
|
|
1998
1933
|
const deliveries = participants.map(({ sub, epoch }) => ({
|
|
1999
1934
|
sub,
|
|
2000
1935
|
epoch,
|
|
@@ -2014,28 +1949,28 @@ export class SessionChannel {
|
|
|
2014
1949
|
return true;
|
|
2015
1950
|
if (!participantsExact())
|
|
2016
1951
|
return false;
|
|
2017
|
-
published =
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
1952
|
+
published = stock.publish(deliveries.map(({ sub, events }) => ({ participant: sub, events })), () => {
|
|
1953
|
+
if (!participantsExact())
|
|
1954
|
+
throw new Error("snapshot lease retired");
|
|
1955
|
+
return this.snapshotContext(pane);
|
|
1956
|
+
}, (participant, event) => participant.onEvent(event));
|
|
1957
|
+
return published && participantsExact();
|
|
2022
1958
|
};
|
|
2023
1959
|
if (!deferPublish && !publish()) {
|
|
2024
1960
|
fail(true);
|
|
2025
1961
|
return;
|
|
2026
1962
|
}
|
|
2027
|
-
for (const { sub, epoch } of deliveries)
|
|
2028
|
-
sub.feed.quarantine(epoch);
|
|
2029
1963
|
settled = true;
|
|
2030
1964
|
done({
|
|
2031
1965
|
ok: true,
|
|
2032
|
-
fingerprint:
|
|
1966
|
+
fingerprint: null,
|
|
2033
1967
|
publish,
|
|
2034
1968
|
hold: () => { },
|
|
2035
1969
|
});
|
|
2036
1970
|
},
|
|
2037
1971
|
}, remaining);
|
|
2038
1972
|
if (!armed) {
|
|
1973
|
+
lease.wireNonce = null;
|
|
2039
1974
|
fail();
|
|
2040
1975
|
return;
|
|
2041
1976
|
}
|
|
@@ -2053,11 +1988,19 @@ export class SessionChannel {
|
|
|
2053
1988
|
// ambiguity: every accepted step has its own ordered reply and any later
|
|
2054
1989
|
// failure can conditionally retire exactly the already-created prefix.
|
|
2055
1990
|
this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${ownerName} ${nonce}`, (ownerReply) => {
|
|
1991
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
1992
|
+
cleanupHook();
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
2056
1995
|
if (!ownerReply.ok || !participantsExact()) {
|
|
2057
1996
|
fail();
|
|
2058
1997
|
return;
|
|
2059
1998
|
}
|
|
2060
1999
|
this.io.commandInline(`set-option -po -t ${pane.runtimeId} ${expectedName} ${tmuxSingleQuote(body)}`, (expectedReply) => {
|
|
2000
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
2001
|
+
cleanupHook();
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2061
2004
|
if (!expectedReply.ok || !participantsExact()) {
|
|
2062
2005
|
fail();
|
|
2063
2006
|
return;
|
|
@@ -2066,113 +2009,6 @@ export class SessionChannel {
|
|
|
2066
2009
|
});
|
|
2067
2010
|
});
|
|
2068
2011
|
}
|
|
2069
|
-
armRecoveryQuiet(recovery, callback) {
|
|
2070
|
-
recovery.cancelQuiet?.();
|
|
2071
|
-
recovery.cancelQuiet = this.scheduleRecovery(() => {
|
|
2072
|
-
recovery.cancelQuiet = null;
|
|
2073
|
-
if (this.recoveryPane(recovery))
|
|
2074
|
-
callback();
|
|
2075
|
-
}, RECOVERY_QUIET_MS);
|
|
2076
|
-
}
|
|
2077
|
-
beginFinalRecovery(recovery) {
|
|
2078
|
-
const pane = this.recoveryPane(recovery);
|
|
2079
|
-
if (!pane)
|
|
2080
|
-
return;
|
|
2081
|
-
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
2082
|
-
this.failRecovery(recovery, "attempts-exhausted");
|
|
2083
|
-
return;
|
|
2084
|
-
}
|
|
2085
|
-
recovery.attempts += 1;
|
|
2086
|
-
recovery.stage = "final";
|
|
2087
|
-
this.noteRecoveryProgress(recovery);
|
|
2088
|
-
// The final read is only a private candidate. Publishing it here can put
|
|
2089
|
-
// expensive replica projection ahead of the confirmation timers and, more
|
|
2090
|
-
// importantly, exposes a snapshot that has not yet survived the two-read
|
|
2091
|
-
// authority proof.
|
|
2092
|
-
this.reseedRecoverySubscribers(pane, recovery, ({ ok, fingerprint }) => {
|
|
2093
|
-
const current = this.recoveryPane(recovery);
|
|
2094
|
-
if (!current)
|
|
2095
|
-
return;
|
|
2096
|
-
if (!ok || fingerprint === null) {
|
|
2097
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2098
|
-
return;
|
|
2099
|
-
}
|
|
2100
|
-
recovery.outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
|
|
2101
|
-
recovery.candidateFingerprint = fingerprint;
|
|
2102
|
-
recovery.confirmationFingerprint = null;
|
|
2103
|
-
recovery.confirmationOrdinal = 0;
|
|
2104
|
-
recovery.stage = "confirm";
|
|
2105
|
-
this.observeRecovery(current, recovery, "final-reseed");
|
|
2106
|
-
this.noteRecoveryProgress(recovery);
|
|
2107
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2108
|
-
}, true);
|
|
2109
|
-
}
|
|
2110
|
-
confirmRecovery(recovery) {
|
|
2111
|
-
const pane = this.recoveryPane(recovery);
|
|
2112
|
-
if (!pane)
|
|
2113
|
-
return;
|
|
2114
|
-
if ((this.outputOrdinals.get(recovery.runtimeId) ?? 0) !== recovery.outputOrdinal) {
|
|
2115
|
-
this.beginFinalRecovery(recovery);
|
|
2116
|
-
return;
|
|
2117
|
-
}
|
|
2118
|
-
recovery.stage = "final";
|
|
2119
|
-
this.noteRecoveryProgress(recovery);
|
|
2120
|
-
this.reseedRecoverySubscribers(pane, recovery, ({ ok, fingerprint, publish }) => {
|
|
2121
|
-
const current = this.recoveryPane(recovery);
|
|
2122
|
-
if (!current)
|
|
2123
|
-
return;
|
|
2124
|
-
if (!ok || fingerprint === null) {
|
|
2125
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2126
|
-
return;
|
|
2127
|
-
}
|
|
2128
|
-
const outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
|
|
2129
|
-
const ordinalExact = outputOrdinal === recovery.outputOrdinal;
|
|
2130
|
-
const candidateExact = ordinalExact && fingerprint === recovery.candidateFingerprint;
|
|
2131
|
-
const consecutiveExact = candidateExact &&
|
|
2132
|
-
recovery.confirmationFingerprint !== null &&
|
|
2133
|
-
fingerprint === recovery.confirmationFingerprint;
|
|
2134
|
-
recovery.confirmationOrdinal += 1;
|
|
2135
|
-
this.observeRecovery(current, recovery, "confirmation-reseed", null, consecutiveExact);
|
|
2136
|
-
this.noteRecoveryProgress(recovery);
|
|
2137
|
-
if (!ordinalExact) {
|
|
2138
|
-
recovery.stage = "confirm";
|
|
2139
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
if (!candidateExact) {
|
|
2143
|
-
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
2144
|
-
this.failRecovery(recovery, "attempts-exhausted");
|
|
2145
|
-
return;
|
|
2146
|
-
}
|
|
2147
|
-
// A changed confirmation replaces the private candidate without
|
|
2148
|
-
// becoming observable. Two subsequent reads must confirm this truth.
|
|
2149
|
-
recovery.attempts += 1;
|
|
2150
|
-
recovery.candidateFingerprint = fingerprint;
|
|
2151
|
-
recovery.confirmationFingerprint = null;
|
|
2152
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2153
|
-
recovery.stage = "confirm";
|
|
2154
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2155
|
-
return;
|
|
2156
|
-
}
|
|
2157
|
-
if (!consecutiveExact) {
|
|
2158
|
-
recovery.confirmationFingerprint = fingerprint;
|
|
2159
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2160
|
-
recovery.stage = "confirm";
|
|
2161
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2162
|
-
return;
|
|
2163
|
-
}
|
|
2164
|
-
// This publisher belongs to the current second matching read, not the
|
|
2165
|
-
// older candidate. Publish exactly once while every participant is
|
|
2166
|
-
// still fenced, then synchronously retire recovery before downstream
|
|
2167
|
-
// projection work can run.
|
|
2168
|
-
if (!publish()) {
|
|
2169
|
-
recovery.stage = "confirm";
|
|
2170
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2171
|
-
return;
|
|
2172
|
-
}
|
|
2173
|
-
this.convergeRecovery(current, recovery);
|
|
2174
|
-
}, true);
|
|
2175
|
-
}
|
|
2176
2012
|
convergeRecovery(pane, recovery) {
|
|
2177
2013
|
recovery.cancelCommandDeadline?.();
|
|
2178
2014
|
recovery.cancelCommandDeadline = null;
|
|
@@ -2182,45 +2018,35 @@ export class SessionChannel {
|
|
|
2182
2018
|
recovery.cancelAbsoluteDeadline = null;
|
|
2183
2019
|
this.recoveries.delete(recovery.runtimeId);
|
|
2184
2020
|
recovery.retired = true;
|
|
2185
|
-
this.retireContinueNotificationOwner(recovery);
|
|
2186
2021
|
this.ledger.noteContinued(recovery.runtimeId);
|
|
2187
2022
|
if (recovery.reason === "requested")
|
|
2188
2023
|
this.ledger.clearRequest(recovery.runtimeId);
|
|
2189
2024
|
for (const sub of pane.subs) {
|
|
2190
2025
|
if (!sub.frozen && !sub.closed) {
|
|
2191
2026
|
sub.feed.releaseQuarantine();
|
|
2192
|
-
|
|
2027
|
+
try {
|
|
2028
|
+
sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
|
|
2029
|
+
}
|
|
2030
|
+
catch {
|
|
2031
|
+
// A consumer cannot strand shared snapshot admission for other panes.
|
|
2032
|
+
}
|
|
2193
2033
|
}
|
|
2194
2034
|
}
|
|
2195
|
-
this.observeRecovery(pane, recovery, "converged", null
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2199
|
-
if (!recovery || recovery.paneIncarnation !== pane.incarnation)
|
|
2200
|
-
return;
|
|
2201
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2202
|
-
if (recovery.continueReply)
|
|
2203
|
-
this.noteRecoveryProgress(recovery);
|
|
2204
|
-
if (recovery.stage === "quiet")
|
|
2205
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2206
|
-
else if (recovery.stage === "confirm")
|
|
2207
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2035
|
+
this.observeRecovery(pane, recovery, "converged", null);
|
|
2036
|
+
if (recovery.lease)
|
|
2037
|
+
this.retireSnapshotLease(recovery.lease);
|
|
2208
2038
|
}
|
|
2209
2039
|
restartRecoveryAfterOutputOverflow(pane) {
|
|
2210
2040
|
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2211
|
-
if (recovery
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
}
|
|
2216
|
-
this.beginLocalOverflowRecovery(pane);
|
|
2041
|
+
if (recovery)
|
|
2042
|
+
this.retrySnapshot(recovery);
|
|
2043
|
+
else
|
|
2044
|
+
this.beginLocalOverflowRecovery(pane);
|
|
2217
2045
|
}
|
|
2218
2046
|
failRecovery(recovery, failureReason) {
|
|
2219
2047
|
const pane = this.recoveryPane(recovery);
|
|
2220
2048
|
if (!pane)
|
|
2221
2049
|
return;
|
|
2222
|
-
recovery.cancelQuiet?.();
|
|
2223
|
-
recovery.cancelQuiet = null;
|
|
2224
2050
|
recovery.cancelCommandDeadline?.();
|
|
2225
2051
|
recovery.cancelCommandDeadline = null;
|
|
2226
2052
|
recovery.cancelNoProgressDeadline?.();
|
|
@@ -2228,14 +2054,16 @@ export class SessionChannel {
|
|
|
2228
2054
|
recovery.cancelAbsoluteDeadline?.();
|
|
2229
2055
|
recovery.cancelAbsoluteDeadline = null;
|
|
2230
2056
|
recovery.retired = true;
|
|
2057
|
+
this.snapshotQueue.delete(recovery.runtimeId);
|
|
2058
|
+
this.recoveries.delete(recovery.runtimeId);
|
|
2059
|
+
for (const sub of pane.subs)
|
|
2060
|
+
sub.feed.abortCurrent();
|
|
2061
|
+
if (recovery.lease)
|
|
2062
|
+
this.retireSnapshotLease(recovery.lease);
|
|
2231
2063
|
const collectorNonce = recovery.atomicCollectorNonce;
|
|
2232
2064
|
recovery.atomicCollectorNonce = null;
|
|
2233
2065
|
if (collectorNonce)
|
|
2234
2066
|
this.io.retireAtomicPaneSnapshotCollector?.(collectorNonce, "retired");
|
|
2235
|
-
this.recoveries.delete(recovery.runtimeId);
|
|
2236
|
-
this.retireContinueNotificationOwner(recovery);
|
|
2237
|
-
for (const sub of pane.subs)
|
|
2238
|
-
sub.feed.abortCurrent();
|
|
2239
2067
|
this.observeRecovery(pane, recovery, "nonconverged", failureReason);
|
|
2240
2068
|
for (const sub of pane.subs) {
|
|
2241
2069
|
sub.cancelCapture?.();
|
|
@@ -2249,50 +2077,6 @@ export class SessionChannel {
|
|
|
2249
2077
|
}
|
|
2250
2078
|
}
|
|
2251
2079
|
}
|
|
2252
|
-
removeContinueNotificationOwner(recovery) {
|
|
2253
|
-
const queue = this.continueNotificationQueues.get(recovery.runtimeId);
|
|
2254
|
-
if (!queue)
|
|
2255
|
-
return;
|
|
2256
|
-
const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
|
|
2257
|
-
if (index < 0)
|
|
2258
|
-
return;
|
|
2259
|
-
queue.splice(index, 1);
|
|
2260
|
-
this.compactContinueNotificationQueue(recovery.runtimeId, queue);
|
|
2261
|
-
}
|
|
2262
|
-
retireContinueNotificationOwner(recovery) {
|
|
2263
|
-
if (!recovery.continueReply)
|
|
2264
|
-
return;
|
|
2265
|
-
const queue = this.continueNotificationQueues.get(recovery.runtimeId);
|
|
2266
|
-
if (!queue)
|
|
2267
|
-
return;
|
|
2268
|
-
const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
|
|
2269
|
-
if (index < 0)
|
|
2270
|
-
return;
|
|
2271
|
-
queue.splice(index, 1, { kind: "debt", count: 1, saturated: false });
|
|
2272
|
-
this.compactContinueNotificationQueue(recovery.runtimeId, queue);
|
|
2273
|
-
}
|
|
2274
|
-
compactContinueNotificationQueue(runtime, queue) {
|
|
2275
|
-
for (let index = 1; index < queue.length;) {
|
|
2276
|
-
const previous = queue[index - 1];
|
|
2277
|
-
const current = queue[index];
|
|
2278
|
-
if (previous?.kind !== "debt" || current?.kind !== "debt") {
|
|
2279
|
-
index += 1;
|
|
2280
|
-
continue;
|
|
2281
|
-
}
|
|
2282
|
-
const total = previous.count + current.count;
|
|
2283
|
-
previous.count = Math.min(total, MAX_CONTINUE_NOTIFICATION_DEBT);
|
|
2284
|
-
previous.saturated =
|
|
2285
|
-
previous.saturated || current.saturated || total > MAX_CONTINUE_NOTIFICATION_DEBT;
|
|
2286
|
-
queue.splice(index, 1);
|
|
2287
|
-
}
|
|
2288
|
-
if (queue.length === 0)
|
|
2289
|
-
this.continueNotificationQueues.delete(runtime);
|
|
2290
|
-
else
|
|
2291
|
-
this.continueNotificationQueues.set(runtime, queue);
|
|
2292
|
-
}
|
|
2293
|
-
/** Continue + reseed EVERY backpressure-paused pane that still has an
|
|
2294
|
-
* unfrozen subscriber. %pause is sticky and hits quiet panes after any
|
|
2295
|
-
* stall — recovering only the noisy pane leaves siblings dark. */
|
|
2296
2080
|
recoverSticky() {
|
|
2297
2081
|
for (const runtime of this.ledger.stickyRecoverySet()) {
|
|
2298
2082
|
if (this.recoveries.has(runtime))
|
|
@@ -2311,6 +2095,10 @@ export class SessionChannel {
|
|
|
2311
2095
|
sub.cancelCapture?.();
|
|
2312
2096
|
const pane = sub.pane;
|
|
2313
2097
|
pane.subs.delete(sub);
|
|
2098
|
+
const activeRecovery = this.recoveries.get(pane.runtimeId);
|
|
2099
|
+
if (activeRecovery &&
|
|
2100
|
+
[...pane.subs].some((candidate) => !candidate.closed && !candidate.frozen))
|
|
2101
|
+
this.retrySnapshot(activeRecovery);
|
|
2314
2102
|
if ([...pane.subs].every((candidate) => candidate.closed || candidate.frozen))
|
|
2315
2103
|
this.cancelRecovery(pane.runtimeId);
|
|
2316
2104
|
// Ticket return on departure: a pane parked by a now-gone subscriber must
|
|
@@ -2321,8 +2109,12 @@ export class SessionChannel {
|
|
|
2321
2109
|
}
|
|
2322
2110
|
}
|
|
2323
2111
|
// ── Notifications (channel order is the invariant) ──────────────────────
|
|
2324
|
-
|
|
2325
|
-
const
|
|
2112
|
+
observeSnapshotMetadata(pane, cursorLine) {
|
|
2113
|
+
const fields = cursorLine.trim().split(/\s+/);
|
|
2114
|
+
const historySize = Number(fields[14]);
|
|
2115
|
+
if (Number.isSafeInteger(historySize) && historySize >= 0)
|
|
2116
|
+
pane.historySize = historySize;
|
|
2117
|
+
const value = fields[22];
|
|
2326
2118
|
pane.scrollOnClear = value === "0" || value === "1" ? value === "1" : undefined;
|
|
2327
2119
|
}
|
|
2328
2120
|
onNotify(name, rest) {
|
|
@@ -2368,9 +2160,16 @@ export class SessionChannel {
|
|
|
2368
2160
|
return;
|
|
2369
2161
|
}
|
|
2370
2162
|
}
|
|
2371
|
-
|
|
2372
|
-
(/^tmux-ide-pane-borders\s+\$[0-9]+\s
|
|
2373
|
-
/^tmux-ide-copy-keys\s+\$[0-9]+\s
|
|
2163
|
+
const windowOptionHint = name === "subscription-changed"
|
|
2164
|
+
? (/^tmux-ide-pane-borders\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:top|bottom|off)\s*$/u.exec(rest) ??
|
|
2165
|
+
/^tmux-ide-copy-keys\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:emacs|vi)\s*$/u.exec(rest))
|
|
2166
|
+
: null;
|
|
2167
|
+
if (windowOptionHint) {
|
|
2168
|
+
// Initial option samples from sibling windows are not evidence that this
|
|
2169
|
+
// pane's capture context changed. Unknown membership stays conservative.
|
|
2170
|
+
for (const pane of this.panesByRuntime.values())
|
|
2171
|
+
if (!pane.windowRuntimeId || pane.windowRuntimeId === windowOptionHint[1])
|
|
2172
|
+
pane.snapshotLayoutGeneration += 1;
|
|
2374
2173
|
this.windowAuthorityOrdinal += 1;
|
|
2375
2174
|
this.windowIdentityOrdinal += 1;
|
|
2376
2175
|
this.scheduleSync();
|
|
@@ -2381,6 +2180,9 @@ export class SessionChannel {
|
|
|
2381
2180
|
name === "session-window-changed" ||
|
|
2382
2181
|
STRUCTURAL_NOTIFICATIONS.has(name)) {
|
|
2383
2182
|
this.windowAuthorityOrdinal += 1;
|
|
2183
|
+
// Structural and layout notifications remain conservative across all panes.
|
|
2184
|
+
for (const pane of this.panesByRuntime.values())
|
|
2185
|
+
pane.snapshotLayoutGeneration += 1;
|
|
2384
2186
|
if (name !== "layout-change")
|
|
2385
2187
|
this.windowIdentityOrdinal += 1;
|
|
2386
2188
|
}
|
|
@@ -2395,8 +2197,19 @@ export class SessionChannel {
|
|
|
2395
2197
|
const runtime = rest.trim().split(/\s+/)[0] ?? "";
|
|
2396
2198
|
if (!runtime.startsWith("%"))
|
|
2397
2199
|
return;
|
|
2398
|
-
|
|
2200
|
+
this.observedPaused.add(runtime);
|
|
2201
|
+
const stockRecovery = this.recoveries.get(runtime);
|
|
2202
|
+
if (stockRecovery?.stock && stockRecovery.lease) {
|
|
2203
|
+
this.ledger.notePause(runtime);
|
|
2204
|
+
if (stockRecovery.stage !== "stock-pause")
|
|
2205
|
+
this.retrySnapshot(stockRecovery);
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
const nativeRecovery = this.recoveries.get(runtime);
|
|
2209
|
+
if (nativeRecovery) {
|
|
2399
2210
|
this.ledger.notePause(runtime);
|
|
2211
|
+
if (nativeRecovery.stage !== "native-pause" && nativeRecovery.stage !== "queued")
|
|
2212
|
+
this.retrySnapshot(nativeRecovery);
|
|
2400
2213
|
return;
|
|
2401
2214
|
}
|
|
2402
2215
|
this.cancelRecovery(runtime);
|
|
@@ -2414,28 +2227,7 @@ export class SessionChannel {
|
|
|
2414
2227
|
}
|
|
2415
2228
|
if (name === "continue") {
|
|
2416
2229
|
const runtime = rest.trim().split(/\s+/)[0] ?? "";
|
|
2417
|
-
|
|
2418
|
-
const queue = this.continueNotificationQueues.get(runtime);
|
|
2419
|
-
const entry = queue?.[0] ?? null;
|
|
2420
|
-
if (entry?.kind === "debt") {
|
|
2421
|
-
if (!entry.saturated) {
|
|
2422
|
-
entry.count -= 1;
|
|
2423
|
-
if (entry.count === 0)
|
|
2424
|
-
queue.shift();
|
|
2425
|
-
}
|
|
2426
|
-
this.compactContinueNotificationQueue(runtime, queue);
|
|
2427
|
-
}
|
|
2428
|
-
else if (entry?.kind === "owner") {
|
|
2429
|
-
queue.shift();
|
|
2430
|
-
this.compactContinueNotificationQueue(runtime, queue);
|
|
2431
|
-
const owner = entry.recovery;
|
|
2432
|
-
const pane = this.recoveryPane(owner);
|
|
2433
|
-
owner.continueNotify = true;
|
|
2434
|
-
if (pane) {
|
|
2435
|
-
this.observeRecovery(pane, owner, "continue-notify");
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
}
|
|
2230
|
+
this.observedPaused.delete(runtime);
|
|
2439
2231
|
return;
|
|
2440
2232
|
}
|
|
2441
2233
|
if (name === "layout-change") {
|
|
@@ -2451,6 +2243,15 @@ export class SessionChannel {
|
|
|
2451
2243
|
return;
|
|
2452
2244
|
}
|
|
2453
2245
|
this.layoutNotificationOrdinals.set(change.windowId, this.windowAuthorityOrdinal);
|
|
2246
|
+
for (const pane of this.panesByRuntime.values()) {
|
|
2247
|
+
if (pane.windowRuntimeId !== change.windowId)
|
|
2248
|
+
continue;
|
|
2249
|
+
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2250
|
+
if (recovery?.lease && !recovery.lease.retired) {
|
|
2251
|
+
this.awaitSnapshotLayout(recovery);
|
|
2252
|
+
this.retrySnapshot(recovery);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2454
2255
|
const pendingLayout = {
|
|
2455
2256
|
...parsed,
|
|
2456
2257
|
zoomed: change.zoomed,
|
|
@@ -2752,6 +2553,8 @@ export class SessionChannel {
|
|
|
2752
2553
|
for (const sub of pane.subs)
|
|
2753
2554
|
sub.resumeLayoutCapture?.(syncOrdinal);
|
|
2754
2555
|
}
|
|
2556
|
+
this.completedSyncOrdinal = Math.max(this.completedSyncOrdinal, syncOrdinal);
|
|
2557
|
+
this.drainSnapshots();
|
|
2755
2558
|
this.discovery.discover(listed);
|
|
2756
2559
|
}
|
|
2757
2560
|
applyPaneTruth(truth) {
|
|
@@ -2777,11 +2580,12 @@ export class SessionChannel {
|
|
|
2777
2580
|
const nextWindowRuntimeId = this.truthWindow.get(runtime) ?? pane.windowRuntimeId;
|
|
2778
2581
|
if (nextWindowRuntimeId !== pane.windowRuntimeId && nextWindowRuntimeId !== null)
|
|
2779
2582
|
movedWindowRuntimeIds.add(nextWindowRuntimeId);
|
|
2583
|
+
if (pane.windowRuntimeId !== nextWindowRuntimeId)
|
|
2584
|
+
pane.snapshotLayoutGeneration += 1;
|
|
2780
2585
|
pane.windowRuntimeId = nextWindowRuntimeId;
|
|
2781
2586
|
continue;
|
|
2782
2587
|
}
|
|
2783
2588
|
this.cancelRecovery(runtime);
|
|
2784
|
-
this.continueNotificationQueues.delete(runtime);
|
|
2785
2589
|
this.panesByRuntime.delete(runtime);
|
|
2786
2590
|
this.outputOrdinals.delete(runtime);
|
|
2787
2591
|
this.panesBySemantic.delete(pane.semanticId);
|
|
@@ -2791,7 +2595,12 @@ export class SessionChannel {
|
|
|
2791
2595
|
if (!sub.closed) {
|
|
2792
2596
|
sub.closed = true;
|
|
2793
2597
|
sub.cancelCapture?.();
|
|
2794
|
-
|
|
2598
|
+
try {
|
|
2599
|
+
sub.onEvent({ type: "closed" });
|
|
2600
|
+
}
|
|
2601
|
+
catch {
|
|
2602
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
2603
|
+
}
|
|
2795
2604
|
}
|
|
2796
2605
|
}
|
|
2797
2606
|
pane.subs.clear();
|
|
@@ -3292,7 +3101,6 @@ export class SessionChannel {
|
|
|
3292
3101
|
// address's bytes are a different pane's now.
|
|
3293
3102
|
const retiredRuntime = existingBySemantic.runtimeId;
|
|
3294
3103
|
this.cancelRecovery(retiredRuntime);
|
|
3295
|
-
this.continueNotificationQueues.delete(retiredRuntime);
|
|
3296
3104
|
this.panesByRuntime.delete(retiredRuntime);
|
|
3297
3105
|
this.outputOrdinals.delete(retiredRuntime);
|
|
3298
3106
|
this.ledger.forget(retiredRuntime);
|
|
@@ -3300,6 +3108,8 @@ export class SessionChannel {
|
|
|
3300
3108
|
existingBySemantic.incarnation = ++this.paneIncarnation;
|
|
3301
3109
|
existingBySemantic.descriptor = descriptor;
|
|
3302
3110
|
existingBySemantic.active = verified.active;
|
|
3111
|
+
if (existingBySemantic.windowRuntimeId !== windowRuntimeId)
|
|
3112
|
+
existingBySemantic.snapshotLayoutGeneration += 1;
|
|
3303
3113
|
existingBySemantic.windowRuntimeId = windowRuntimeId;
|
|
3304
3114
|
this.panesByRuntime.set(verified.runtimePaneId, existingBySemantic);
|
|
3305
3115
|
for (const sub of existingBySemantic.subs) {
|
|
@@ -3311,6 +3121,8 @@ export class SessionChannel {
|
|
|
3311
3121
|
if (existingByRuntime && existingByRuntime.semanticId === verified.semanticPaneId) {
|
|
3312
3122
|
existingByRuntime.descriptor = descriptor;
|
|
3313
3123
|
existingByRuntime.active = verified.active;
|
|
3124
|
+
if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
|
|
3125
|
+
existingByRuntime.snapshotLayoutGeneration += 1;
|
|
3314
3126
|
existingByRuntime.windowRuntimeId = windowRuntimeId;
|
|
3315
3127
|
continue;
|
|
3316
3128
|
}
|
|
@@ -3319,7 +3131,6 @@ export class SessionChannel {
|
|
|
3319
3131
|
// resolution). The old semantic id is gone.
|
|
3320
3132
|
this.panesBySemantic.delete(existingByRuntime.semanticId);
|
|
3321
3133
|
this.cancelRecovery(existingByRuntime.runtimeId);
|
|
3322
|
-
this.continueNotificationQueues.delete(existingByRuntime.runtimeId);
|
|
3323
3134
|
this.ledger.forget(existingByRuntime.runtimeId);
|
|
3324
3135
|
this.outputOrdinals.delete(existingByRuntime.runtimeId);
|
|
3325
3136
|
for (const sub of existingByRuntime.subs) {
|
|
@@ -3328,13 +3139,20 @@ export class SessionChannel {
|
|
|
3328
3139
|
sub.closed = true;
|
|
3329
3140
|
sub.cancelCapture?.();
|
|
3330
3141
|
sub.feed.abortCurrent();
|
|
3331
|
-
|
|
3142
|
+
try {
|
|
3143
|
+
sub.onEvent({ type: "closed" });
|
|
3144
|
+
}
|
|
3145
|
+
catch {
|
|
3146
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
3147
|
+
}
|
|
3332
3148
|
}
|
|
3333
3149
|
existingByRuntime.subs.clear();
|
|
3334
3150
|
existingByRuntime.incarnation = ++this.paneIncarnation;
|
|
3335
3151
|
existingByRuntime.semanticId = verified.semanticPaneId;
|
|
3336
3152
|
existingByRuntime.descriptor = descriptor;
|
|
3337
3153
|
existingByRuntime.active = verified.active;
|
|
3154
|
+
if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
|
|
3155
|
+
existingByRuntime.snapshotLayoutGeneration += 1;
|
|
3338
3156
|
existingByRuntime.windowRuntimeId = windowRuntimeId;
|
|
3339
3157
|
this.panesBySemantic.set(verified.semanticPaneId, existingByRuntime);
|
|
3340
3158
|
continue;
|
|
@@ -3347,6 +3165,7 @@ export class SessionChannel {
|
|
|
3347
3165
|
windowRuntimeId,
|
|
3348
3166
|
subs: new Set(),
|
|
3349
3167
|
incarnation: ++this.paneIncarnation,
|
|
3168
|
+
snapshotLayoutGeneration: 0,
|
|
3350
3169
|
};
|
|
3351
3170
|
this.panesByRuntime.set(record.runtimeId, record);
|
|
3352
3171
|
this.panesBySemantic.set(record.semanticId, record);
|
|
@@ -3438,19 +3257,28 @@ export class SessionChannel {
|
|
|
3438
3257
|
onChannelExit() {
|
|
3439
3258
|
if (this.disposed)
|
|
3440
3259
|
return;
|
|
3260
|
+
this.snapshotActive?.cancelFence?.();
|
|
3261
|
+
this.snapshotActive = null;
|
|
3262
|
+
this.snapshotQueue.clear();
|
|
3263
|
+
for (const recovery of this.recoveries.values())
|
|
3264
|
+
recovery.lease = undefined;
|
|
3441
3265
|
this.opts.ownedViewer?.dispose();
|
|
3442
3266
|
this.windowLinkAuthority?.dispose();
|
|
3443
3267
|
this.nativeGrid.dispose();
|
|
3444
3268
|
this.settleFirstJoin();
|
|
3445
3269
|
for (const runtime of [...this.recoveries.keys()])
|
|
3446
3270
|
this.cancelRecovery(runtime);
|
|
3447
|
-
this.continueNotificationQueues.clear();
|
|
3448
3271
|
for (const pane of this.panesByRuntime.values()) {
|
|
3449
3272
|
for (const sub of pane.subs) {
|
|
3450
3273
|
if (!sub.closed) {
|
|
3451
3274
|
sub.closed = true;
|
|
3452
3275
|
sub.cancelCapture?.();
|
|
3453
|
-
|
|
3276
|
+
try {
|
|
3277
|
+
sub.onEvent({ type: "closed" });
|
|
3278
|
+
}
|
|
3279
|
+
catch {
|
|
3280
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
3281
|
+
}
|
|
3454
3282
|
}
|
|
3455
3283
|
}
|
|
3456
3284
|
pane.subs.clear();
|