tmux-ide 2.9.3 → 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/LICENSE +1 -1
- package/README.md +120 -139
- package/bin/cli.js +4847 -3585
- package/package.json +16 -9
- 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/integrations/claude-agent-teams.js +264 -0
- 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/integrations/claude-agent-teams.ts +334 -0
- 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
- package/skill/SKILL.md +30 -30
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
* keys flush pending literals first — the shared {@link InputCoalescer}
|
|
42
42
|
* discipline — leaving fire-and-forget via the channel.
|
|
43
43
|
*/
|
|
44
|
-
import {
|
|
44
|
+
import { randomBytes } from "node:crypto";
|
|
45
45
|
import { hostname } from "node:os";
|
|
46
46
|
import {
|
|
47
47
|
WINDOW_LINK_MAX_LINKS,
|
|
@@ -105,6 +105,7 @@ import {
|
|
|
105
105
|
} from "../../lib/tmux-window-link-guard.ts";
|
|
106
106
|
import { FlowLedger } from "./flow-ledger.ts";
|
|
107
107
|
import { PaneFeed, captureLinesFromAnsiBytes, parseCursorProbe } from "./pane-feed.ts";
|
|
108
|
+
import { StockPaneSnapshot, type StockPaneSnapshotContext } from "./stock-pane-snapshot.ts";
|
|
108
109
|
import type {
|
|
109
110
|
TrustedMirrorPaneInventory,
|
|
110
111
|
TrustedMirrorSessionInventory,
|
|
@@ -140,20 +141,17 @@ const NATIVE_CLIENT_SUBSCRIPTION = "tmux-ide-native-clients";
|
|
|
140
141
|
const SYNC_DEBOUNCE_MS = 40;
|
|
141
142
|
/** Foreground-command labels follow output, but never turn a busy pane into a probe loop. */
|
|
142
143
|
const DISPLAY_NAME_SYNC_INTERVAL_MS = 750;
|
|
143
|
-
const RECOVERY_QUIET_MS = 40;
|
|
144
144
|
const RECOVERY_COMMAND_DEADLINE_MS = 500;
|
|
145
145
|
const RECOVERY_NO_PROGRESS_DEADLINE_MS = 3_000;
|
|
146
146
|
const RECOVERY_ABSOLUTE_DEADLINE_MS = 5_000;
|
|
147
147
|
const RECOVERY_MAX_ATTEMPTS = 4;
|
|
148
|
-
const
|
|
148
|
+
const MAX_QUEUED_SNAPSHOTS = 64;
|
|
149
149
|
const RECOVERY_CAPTURE_MAX_BYTES = 16 * 1024 * 1024;
|
|
150
150
|
// Budget row bookkeeping separately from wire bytes (64 bytes per retained
|
|
151
151
|
// line). A fixed 8192-row ceiling rejected small captures of ordinary history.
|
|
152
152
|
// Both the wire-byte cap and this finite allocation bound remain enforced.
|
|
153
153
|
const RECOVERY_CAPTURE_MAX_LINES = RECOVERY_CAPTURE_MAX_BYTES / 64;
|
|
154
154
|
const RECOVERY_CURSOR_MAX_BYTES = 1_024;
|
|
155
|
-
const MAX_CONTINUE_NOTIFICATION_QUEUE = 32;
|
|
156
|
-
const MAX_CONTINUE_NOTIFICATION_DEBT = 65_536;
|
|
157
155
|
const RECOVERY_CURSOR_PROBE_FORMAT = [
|
|
158
156
|
"#{cursor_x}",
|
|
159
157
|
"#{cursor_y}",
|
|
@@ -269,6 +267,18 @@ export interface SessionChannelOptions {
|
|
|
269
267
|
onExit?: () => void;
|
|
270
268
|
/** Event-driven proof that a non-control tmux client is actively attached. */
|
|
271
269
|
onNativeClientActivity?: () => void;
|
|
270
|
+
/** Optional diagnostic capture before dispatch; completion must not affect input. */
|
|
271
|
+
captureInputWrite?: (
|
|
272
|
+
action: InputAction,
|
|
273
|
+
semanticPaneId: string | null,
|
|
274
|
+
) =>
|
|
275
|
+
| ((
|
|
276
|
+
startedAtMicros: number,
|
|
277
|
+
endedAtMicros: number,
|
|
278
|
+
pendingBeforeSend: number,
|
|
279
|
+
paneCurrent: boolean,
|
|
280
|
+
) => void)
|
|
281
|
+
| undefined;
|
|
272
282
|
onInputWrite?: (
|
|
273
283
|
action: InputAction,
|
|
274
284
|
startedAtMicros: number,
|
|
@@ -312,11 +322,6 @@ interface SubRecord {
|
|
|
312
322
|
closed: boolean;
|
|
313
323
|
}
|
|
314
324
|
|
|
315
|
-
interface PlainReseedLease {
|
|
316
|
-
readonly sub: SubRecord;
|
|
317
|
-
cancelRecipe: (() => void) | null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
325
|
interface PaneRecord {
|
|
321
326
|
historySize?: number;
|
|
322
327
|
scrollOnClear?: boolean;
|
|
@@ -327,18 +332,36 @@ interface PaneRecord {
|
|
|
327
332
|
windowRuntimeId: string | null;
|
|
328
333
|
readonly subs: Set<SubRecord>;
|
|
329
334
|
incarnation: number;
|
|
335
|
+
snapshotLayoutGeneration: number;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface SnapshotLease {
|
|
339
|
+
readonly recovery: RecoveryRecord;
|
|
340
|
+
retired: boolean;
|
|
341
|
+
retry: boolean;
|
|
342
|
+
wireNonce: string | null;
|
|
343
|
+
fencePending: boolean;
|
|
344
|
+
cancelFence: (() => void) | null;
|
|
345
|
+
cleanup?: () => void;
|
|
346
|
+
ownsPause?: boolean;
|
|
330
347
|
}
|
|
331
348
|
|
|
332
349
|
interface RecoveryRecord {
|
|
350
|
+
nativeProbeAttempts?: number;
|
|
351
|
+
waitForSyncAfter?: number;
|
|
352
|
+
nativeOwner?: NativeAtomicSnapshotTarget;
|
|
353
|
+
lease?: SnapshotLease;
|
|
354
|
+
stock?: StockPaneSnapshot;
|
|
333
355
|
readonly ordinal: number;
|
|
334
356
|
readonly runtimeId: string;
|
|
335
357
|
readonly paneIncarnation: number;
|
|
336
358
|
readonly reason: "backpressure" | "requested";
|
|
337
|
-
|
|
359
|
+
startedAtMs: number;
|
|
338
360
|
retired: boolean;
|
|
339
|
-
continueReply: boolean;
|
|
340
|
-
continueNotify: boolean;
|
|
341
361
|
stage:
|
|
362
|
+
| "queued"
|
|
363
|
+
| "stock-pause"
|
|
364
|
+
| "stock-capture"
|
|
342
365
|
| "native-pause"
|
|
343
366
|
| "native-capture"
|
|
344
367
|
| "continue"
|
|
@@ -349,9 +372,6 @@ interface RecoveryRecord {
|
|
|
349
372
|
| "confirm";
|
|
350
373
|
attempts: number;
|
|
351
374
|
reseedOrdinal: number;
|
|
352
|
-
outputOrdinal: number;
|
|
353
|
-
candidateFingerprint: string | null;
|
|
354
|
-
confirmationFingerprint: string | null;
|
|
355
375
|
confirmationOrdinal: number;
|
|
356
376
|
atomicCollectorNonce: string | null;
|
|
357
377
|
collectorStarted: boolean;
|
|
@@ -362,7 +382,6 @@ interface RecoveryRecord {
|
|
|
362
382
|
collectorStatusObserved: boolean;
|
|
363
383
|
collectorObserverEmissionObserved: boolean;
|
|
364
384
|
collectorFailureReason: AtomicPaneSnapshotFailureReason | null;
|
|
365
|
-
cancelQuiet: (() => void) | null;
|
|
366
385
|
cancelCommandDeadline: (() => void) | null;
|
|
367
386
|
cancelNoProgressDeadline: (() => void) | null;
|
|
368
387
|
cancelAbsoluteDeadline: (() => void) | null;
|
|
@@ -382,52 +401,12 @@ const FAILED_RESEED_RESULT: ReseedResult = Object.freeze({
|
|
|
382
401
|
hold: () => {},
|
|
383
402
|
});
|
|
384
403
|
|
|
385
|
-
function snapshotFingerprint(
|
|
386
|
-
captureLines: readonly string[],
|
|
387
|
-
cursorLine: string,
|
|
388
|
-
fallbackSize: { cols: number; rows: number } | null,
|
|
389
|
-
): string {
|
|
390
|
-
const hash = createHash("sha256");
|
|
391
|
-
const append = (bytes: Uint8Array): void => {
|
|
392
|
-
const length = Buffer.allocUnsafe(4);
|
|
393
|
-
length.writeUInt32BE(bytes.byteLength);
|
|
394
|
-
hash.update(length);
|
|
395
|
-
hash.update(bytes);
|
|
396
|
-
};
|
|
397
|
-
hash.update("tmux-ide/recovery-snapshot/v1\0");
|
|
398
|
-
const count = Buffer.allocUnsafe(4);
|
|
399
|
-
count.writeUInt32BE(captureLines.length);
|
|
400
|
-
hash.update(count);
|
|
401
|
-
for (const line of captureLines) append(Buffer.from(line, "latin1"));
|
|
402
|
-
append(Buffer.from(cursorLine, "utf8"));
|
|
403
|
-
append(
|
|
404
|
-
Buffer.from(
|
|
405
|
-
fallbackSize ? `${fallbackSize.cols}x${fallbackSize.rows}` : "no-layout-fallback",
|
|
406
|
-
"ascii",
|
|
407
|
-
),
|
|
408
|
-
);
|
|
409
|
-
return hash.digest("hex");
|
|
410
|
-
}
|
|
411
|
-
|
|
412
404
|
// tmux concatenates adjacent quoted/unquoted fragments; doubled single quotes
|
|
413
405
|
// do not escape a quote. Preserve nested commands across both parser passes.
|
|
414
406
|
function tmuxSingleQuote(value: string): string {
|
|
415
407
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
416
408
|
}
|
|
417
409
|
|
|
418
|
-
interface ContinueNotificationOwner {
|
|
419
|
-
readonly kind: "owner";
|
|
420
|
-
readonly recovery: RecoveryRecord;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
interface ContinueNotificationDebt {
|
|
424
|
-
readonly kind: "debt";
|
|
425
|
-
count: number;
|
|
426
|
-
saturated: boolean;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
type ContinueNotificationEntry = ContinueNotificationOwner | ContinueNotificationDebt;
|
|
430
|
-
|
|
431
410
|
interface WindowRecord {
|
|
432
411
|
runtimeId: string;
|
|
433
412
|
semanticId: string | null;
|
|
@@ -472,7 +451,6 @@ export function defaultMirrorWindowId(): string {
|
|
|
472
451
|
export class SessionChannel {
|
|
473
452
|
private nativeBootstrapUnavailable = false;
|
|
474
453
|
private nativeBootstrapConfirmed = false;
|
|
475
|
-
private readonly nativeProbeRetried = new WeakSet<SubRecord>();
|
|
476
454
|
private readonly opts: SessionChannelOptions;
|
|
477
455
|
private readonly io: MirrorChannelIo;
|
|
478
456
|
private readonly ledger = new FlowLedger();
|
|
@@ -504,6 +482,7 @@ export class SessionChannel {
|
|
|
504
482
|
private readonly fittedWindows = new Map<string, { cols: number; rows: number }>();
|
|
505
483
|
private cancelSync: (() => void) | null = null;
|
|
506
484
|
private syncOrdinal = 0;
|
|
485
|
+
private completedSyncOrdinal = 0;
|
|
507
486
|
private lastDisplayNameSyncAtMs = 0;
|
|
508
487
|
private disposed = false;
|
|
509
488
|
private readonly nativeGrid: NativeGridCaptureReader;
|
|
@@ -514,8 +493,9 @@ export class SessionChannel {
|
|
|
514
493
|
private windowIdentityOrdinal = 0;
|
|
515
494
|
private paneIncarnation = 0;
|
|
516
495
|
private recoveryOrdinal = 0;
|
|
517
|
-
private
|
|
518
|
-
private
|
|
496
|
+
private readonly snapshotQueue = new Map<string, RecoveryRecord>();
|
|
497
|
+
private snapshotActive: SnapshotLease | null = null;
|
|
498
|
+
private readonly observedPaused = new Set<string>();
|
|
519
499
|
private readonly outputOrdinals = new Map<string, number>();
|
|
520
500
|
private readonly pendingLayoutOutput = new Map<
|
|
521
501
|
string,
|
|
@@ -532,7 +512,6 @@ export class SessionChannel {
|
|
|
532
512
|
}
|
|
533
513
|
>();
|
|
534
514
|
private readonly recoveries = new Map<string, RecoveryRecord>();
|
|
535
|
-
private readonly continueNotificationQueues = new Map<string, ContinueNotificationEntry[]>();
|
|
536
515
|
private trustedInventoryFlight: Promise<TrustedMirrorSessionInventory> | null = null;
|
|
537
516
|
private trustedInventoryFlightSessionId: string | null = null;
|
|
538
517
|
private attachedIdentity: { sessionName: string; runtimeSessionId: string } | null = null;
|
|
@@ -546,6 +525,18 @@ export class SessionChannel {
|
|
|
546
525
|
(action) => {
|
|
547
526
|
const startedAtMicros = action.traceIds?.length ? Math.floor(performance.now() * 1_000) : 0;
|
|
548
527
|
const pendingBeforeSend = action.traceIds?.length ? (this.io.pendingCount ?? 0) : 0;
|
|
528
|
+
const pane = action.traceIds?.length ? this.panesByRuntime.get(action.pane) : undefined;
|
|
529
|
+
const semanticPaneId = !this.disposed && pane?.descriptor ? pane.semanticId : null;
|
|
530
|
+
const paneIncarnation = pane?.incarnation;
|
|
531
|
+
const paneActive = pane?.active;
|
|
532
|
+
const birth = pane?.descriptor?.nativePaneBirthId;
|
|
533
|
+
let complete: ReturnType<NonNullable<SessionChannelOptions["captureInputWrite"]>>;
|
|
534
|
+
try {
|
|
535
|
+
if (action.traceIds?.length)
|
|
536
|
+
complete = this.opts.captureInputWrite?.(action, semanticPaneId);
|
|
537
|
+
} catch {
|
|
538
|
+
// Optional diagnostics must never suppress a dispatch.
|
|
539
|
+
}
|
|
549
540
|
const onReply = action.traceIds?.length
|
|
550
541
|
? (reply: { ok: boolean }) =>
|
|
551
542
|
this.opts.onInputAccepted?.(action, Math.floor(performance.now() * 1_000), reply.ok)
|
|
@@ -566,6 +557,26 @@ export class SessionChannel {
|
|
|
566
557
|
} else {
|
|
567
558
|
this.io.send(`send-keys -t ${action.pane} ${action.key}`, onReply);
|
|
568
559
|
}
|
|
560
|
+
try {
|
|
561
|
+
complete?.(
|
|
562
|
+
startedAtMicros,
|
|
563
|
+
Math.floor(performance.now() * 1_000),
|
|
564
|
+
pendingBeforeSend,
|
|
565
|
+
!this.disposed &&
|
|
566
|
+
pane !== undefined &&
|
|
567
|
+
semanticPaneId !== null &&
|
|
568
|
+
this.panesByRuntime.get(action.pane) === pane &&
|
|
569
|
+
this.panesBySemantic.get(semanticPaneId) === pane &&
|
|
570
|
+
pane.runtimeId === action.pane &&
|
|
571
|
+
pane.semanticId === semanticPaneId &&
|
|
572
|
+
pane.incarnation === paneIncarnation &&
|
|
573
|
+
pane.active === paneActive &&
|
|
574
|
+
pane.descriptor !== null &&
|
|
575
|
+
pane.descriptor.nativePaneBirthId === birth,
|
|
576
|
+
);
|
|
577
|
+
} catch {
|
|
578
|
+
// In particular, never replay an accepted owned-native dispatch.
|
|
579
|
+
}
|
|
569
580
|
if (action.traceIds?.length)
|
|
570
581
|
this.opts.onInputWrite?.(
|
|
571
582
|
action,
|
|
@@ -670,7 +681,10 @@ export class SessionChannel {
|
|
|
670
681
|
marker = value;
|
|
671
682
|
},
|
|
672
683
|
(reply) => {
|
|
673
|
-
if (
|
|
684
|
+
if (marker) {
|
|
685
|
+
if (reply.ok) this.clearInternalReadMarkerOption(runtime, marker);
|
|
686
|
+
else this.retireInternalReadMarker(runtime, marker);
|
|
687
|
+
}
|
|
674
688
|
onReply(reply);
|
|
675
689
|
},
|
|
676
690
|
limits,
|
|
@@ -1013,6 +1027,7 @@ export class SessionChannel {
|
|
|
1013
1027
|
frozen: false,
|
|
1014
1028
|
closed: false,
|
|
1015
1029
|
};
|
|
1030
|
+
sub.feed.abortCurrent();
|
|
1016
1031
|
pane.subs.add(sub);
|
|
1017
1032
|
// A paused pane gains an unfrozen watcher: release the park before the
|
|
1018
1033
|
// seed so the capture reflects a flowing pane.
|
|
@@ -1069,6 +1084,14 @@ export class SessionChannel {
|
|
|
1069
1084
|
};
|
|
1070
1085
|
}
|
|
1071
1086
|
|
|
1087
|
+
qualificationListeners() {
|
|
1088
|
+
return Object.freeze({
|
|
1089
|
+
pane: [...this.panesByRuntime.values()].reduce((sum, pane) => sum + pane.subs.size, 0),
|
|
1090
|
+
layout: this.layoutSubscribers.size,
|
|
1091
|
+
layoutAuthority: this.layoutAuthoritySubscribers.size,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1072
1095
|
/** Session geometry without a dummy pane feed or terminal-content seed. */
|
|
1073
1096
|
subscribeLayout(onLayout: (event: MirrorLayoutEvent) => void): LayoutSubscriptionHandle {
|
|
1074
1097
|
if (this.disposed) throw new Error(`mirror session ${this.opts.session} is disposed`);
|
|
@@ -1299,13 +1322,16 @@ export class SessionChannel {
|
|
|
1299
1322
|
async dispose(): Promise<void> {
|
|
1300
1323
|
if (this.disposed) return;
|
|
1301
1324
|
this.disposed = true;
|
|
1325
|
+
this.snapshotActive?.cleanup?.();
|
|
1326
|
+
this.snapshotActive?.cancelFence?.();
|
|
1327
|
+
this.snapshotActive = null;
|
|
1328
|
+
this.snapshotQueue.clear();
|
|
1302
1329
|
this.windowLinkAuthority?.dispose();
|
|
1303
1330
|
this.nativeGrid.dispose();
|
|
1304
1331
|
this.settleFirstJoin();
|
|
1305
1332
|
this.cancelSync?.();
|
|
1306
1333
|
this.cancelSync = null;
|
|
1307
1334
|
for (const runtime of [...this.recoveries.keys()]) this.cancelRecovery(runtime);
|
|
1308
|
-
this.continueNotificationQueues.clear();
|
|
1309
1335
|
this.discovery.dispose();
|
|
1310
1336
|
this.input.flush();
|
|
1311
1337
|
this.opts.ownedViewer?.dispose();
|
|
@@ -1314,7 +1340,11 @@ export class SessionChannel {
|
|
|
1314
1340
|
if (!sub.closed) {
|
|
1315
1341
|
sub.closed = true;
|
|
1316
1342
|
sub.cancelCapture?.();
|
|
1317
|
-
|
|
1343
|
+
try {
|
|
1344
|
+
sub.onEvent({ type: "closed" });
|
|
1345
|
+
} catch {
|
|
1346
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
1347
|
+
}
|
|
1318
1348
|
}
|
|
1319
1349
|
}
|
|
1320
1350
|
pane.subs.clear();
|
|
@@ -1334,6 +1364,26 @@ export class SessionChannel {
|
|
|
1334
1364
|
ageMs: number | null,
|
|
1335
1365
|
timing?: MirrorOutputTiming,
|
|
1336
1366
|
): void {
|
|
1367
|
+
const stockRecovery = this.recoveries.get(runtimePane);
|
|
1368
|
+
const stockPane = this.panesByRuntime.get(runtimePane);
|
|
1369
|
+
if (stockRecovery?.stock && stockPane && stockRecovery.lease) {
|
|
1370
|
+
const disposition = stockRecovery.stock.acceptOutput(
|
|
1371
|
+
runtimePane,
|
|
1372
|
+
data,
|
|
1373
|
+
this.snapshotContext(stockPane),
|
|
1374
|
+
);
|
|
1375
|
+
if (disposition !== "live" && disposition !== "unrelated") {
|
|
1376
|
+
const ordinal = (this.outputOrdinals.get(runtimePane) ?? 0) + 1;
|
|
1377
|
+
this.outputOrdinals.set(runtimePane, ordinal);
|
|
1378
|
+
this.opts.onOutputObserved?.(stockPane.semanticId, ageMs, timing);
|
|
1379
|
+
if (ageMs !== null) {
|
|
1380
|
+
this.ageByRuntime.set(runtimePane, ageMs);
|
|
1381
|
+
if (ageMs > this.maxAgeMs) this.maxAgeMs = ageMs;
|
|
1382
|
+
}
|
|
1383
|
+
if (disposition === "invalid") this.retrySnapshot(stockRecovery);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1337
1387
|
const windowId = this.panesByRuntime.get(runtimePane)?.windowRuntimeId;
|
|
1338
1388
|
const pending = windowId ? this.pendingLayoutOutput.get(windowId) : undefined;
|
|
1339
1389
|
if (pending) {
|
|
@@ -1367,334 +1417,29 @@ export class SessionChannel {
|
|
|
1367
1417
|
if (sub.feed.takeOverflowed()) overflowed = true;
|
|
1368
1418
|
}
|
|
1369
1419
|
if (overflowed) this.restartRecoveryAfterOutputOverflow(pane);
|
|
1370
|
-
this.noteRecoveryOutput(pane, outputOrdinal);
|
|
1371
1420
|
}
|
|
1372
1421
|
|
|
1373
1422
|
// ── Seed / reseed (the atomic recipe) ────────────────────────────────────
|
|
1374
1423
|
|
|
1375
|
-
private reseed(
|
|
1376
|
-
lease: PlainReseedLease,
|
|
1377
|
-
onSettled?: (result: ReseedResult) => void,
|
|
1378
|
-
deferPublish = false,
|
|
1379
|
-
deadlineAt = this.recoveryNowMs() + RECOVERY_ABSOLUTE_DEADLINE_MS,
|
|
1380
|
-
): void {
|
|
1381
|
-
const { sub } = lease;
|
|
1382
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
1383
|
-
onSettled?.(FAILED_RESEED_RESULT);
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
lease.cancelRecipe?.();
|
|
1387
|
-
const runtime = sub.pane.runtimeId;
|
|
1388
|
-
const epoch = sub.feed.beginReseed();
|
|
1389
|
-
let settled = false;
|
|
1390
|
-
let captureSucceeded = false;
|
|
1391
|
-
let markerRetired = false;
|
|
1392
|
-
let captureLines: readonly string[] | null = null;
|
|
1393
|
-
let capturedNativeSize: { cols: number; rows: number } | null = null;
|
|
1394
|
-
let cancelDeadline: (() => void) | null = null;
|
|
1395
|
-
let resumeLayoutCapture: ((syncOrdinal?: number) => void) | null = null;
|
|
1396
|
-
const clearLayoutWait = () => {
|
|
1397
|
-
if (sub.resumeLayoutCapture === resumeLayoutCapture) sub.resumeLayoutCapture = null;
|
|
1398
|
-
resumeLayoutCapture = null;
|
|
1399
|
-
};
|
|
1400
|
-
const settle = (result: ReseedResult) => {
|
|
1401
|
-
if (settled) return;
|
|
1402
|
-
settled = true;
|
|
1403
|
-
clearLayoutWait();
|
|
1404
|
-
cancelDeadline?.();
|
|
1405
|
-
lease.cancelRecipe = null;
|
|
1406
|
-
onSettled?.(result);
|
|
1407
|
-
};
|
|
1408
|
-
// Keystroke ordering: pending coalesced input leaves before the probes.
|
|
1409
|
-
this.input.flush();
|
|
1410
|
-
const history = this.opts.historyLines ?? "";
|
|
1411
|
-
let internalReadMarker: string | null = null;
|
|
1412
|
-
const retireMarker = (): void => {
|
|
1413
|
-
if (markerRetired) return;
|
|
1414
|
-
markerRetired = true;
|
|
1415
|
-
if (internalReadMarker) this.retireInternalReadMarker(runtime, internalReadMarker);
|
|
1416
|
-
};
|
|
1417
|
-
lease.cancelRecipe = () => {
|
|
1418
|
-
if (settled) return;
|
|
1419
|
-
settled = true;
|
|
1420
|
-
clearLayoutWait();
|
|
1421
|
-
cancelDeadline?.();
|
|
1422
|
-
lease.cancelRecipe = null;
|
|
1423
|
-
sub.feed.abort(epoch);
|
|
1424
|
-
if (!captureSucceeded) retireMarker();
|
|
1425
|
-
};
|
|
1426
|
-
cancelDeadline = this.scheduleRecovery(
|
|
1427
|
-
() => {
|
|
1428
|
-
if (settled) return;
|
|
1429
|
-
sub.feed.abort(epoch);
|
|
1430
|
-
if (!captureSucceeded) retireMarker();
|
|
1431
|
-
settle(FAILED_RESEED_RESULT);
|
|
1432
|
-
},
|
|
1433
|
-
Math.max(0, deadlineAt - this.recoveryNowMs()),
|
|
1434
|
-
);
|
|
1435
|
-
// Keep retired reply slots in the control FIFO; their callbacks become
|
|
1436
|
-
// no-ops so late responses cannot publish or consume a newer capture.
|
|
1437
|
-
// Both probes ride one write burst; the FIFO reply order is the seam.
|
|
1438
|
-
this.captureWithViewer(
|
|
1439
|
-
runtime,
|
|
1440
|
-
[...(sub.nativeBootstrap ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`],
|
|
1441
|
-
(marker) => {
|
|
1442
|
-
internalReadMarker = marker;
|
|
1443
|
-
},
|
|
1444
|
-
(reply) => {
|
|
1445
|
-
if (settled) return;
|
|
1446
|
-
const native =
|
|
1447
|
-
sub.nativeBootstrap && reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
|
|
1448
|
-
if (
|
|
1449
|
-
sub.nativeBootstrap &&
|
|
1450
|
-
(!native || !isNativeBootstrapCapture(native)) &&
|
|
1451
|
-
!nativeBootstrapUnsupported(reply.ok, reply.lines, native)
|
|
1452
|
-
) {
|
|
1453
|
-
if (
|
|
1454
|
-
!this.nativeBootstrapConfirmed &&
|
|
1455
|
-
!this.nativeProbeRetried.has(sub) &&
|
|
1456
|
-
this.recoveryNowMs() < deadlineAt
|
|
1457
|
-
) {
|
|
1458
|
-
// An unknown server can swallow an unsupported command inside a
|
|
1459
|
-
// dynamic hook. Retry the ordinary probe once, with a fresh FIFO
|
|
1460
|
-
// seam and the original deadline, before invoking native recovery.
|
|
1461
|
-
this.nativeProbeRetried.add(sub);
|
|
1462
|
-
settled = true;
|
|
1463
|
-
sub.feed.abort(epoch);
|
|
1464
|
-
cancelDeadline?.();
|
|
1465
|
-
lease.cancelRecipe = null;
|
|
1466
|
-
retireMarker();
|
|
1467
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
sub.feed.abort(epoch);
|
|
1471
|
-
retireMarker();
|
|
1472
|
-
settle(FAILED_RESEED_RESULT);
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
|
-
if (sub.nativeBootstrap && (!native || !isNativeBootstrapCapture(native))) {
|
|
1476
|
-
// Unsupported/backing-only native exports fall back at a new FIFO
|
|
1477
|
-
// capture seam. Never replay held bytes across these two captures.
|
|
1478
|
-
settled = true;
|
|
1479
|
-
retireMarker();
|
|
1480
|
-
sub.feed.abort(epoch);
|
|
1481
|
-
cancelDeadline?.();
|
|
1482
|
-
lease.cancelRecipe = null;
|
|
1483
|
-
sub.nativeBootstrap = false;
|
|
1484
|
-
this.nativeBootstrapUnavailable = true;
|
|
1485
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
if (!reply.ok) {
|
|
1489
|
-
// Successful captures consume the marker atomically inside the tmux
|
|
1490
|
-
// after-capture-pane hook. The command-list also prevents a concurrent
|
|
1491
|
-
// mirror from stealing the marker. Only failures need cleanup.
|
|
1492
|
-
retireMarker();
|
|
1493
|
-
sub.feed.abort(epoch);
|
|
1494
|
-
settle(FAILED_RESEED_RESULT);
|
|
1495
|
-
return;
|
|
1496
|
-
}
|
|
1497
|
-
captureSucceeded = true;
|
|
1498
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
1499
|
-
sub.feed.abort(epoch);
|
|
1500
|
-
settle(FAILED_RESEED_RESULT);
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
captureLines = [...reply.lines];
|
|
1504
|
-
if (native) {
|
|
1505
|
-
this.nativeBootstrapConfirmed = true;
|
|
1506
|
-
capturedNativeSize = { cols: native.cols, rows: native.rows };
|
|
1507
|
-
}
|
|
1508
|
-
if (native) sub.feed.captureNativeReply(epoch, native);
|
|
1509
|
-
else sub.feed.captureReply(epoch, reply.lines);
|
|
1510
|
-
},
|
|
1511
|
-
);
|
|
1512
|
-
this.io.commandInline(
|
|
1513
|
-
`display-message -p -t ${runtime} "${RECOVERY_CURSOR_PROBE_FORMAT}"`,
|
|
1514
|
-
(reply) => {
|
|
1515
|
-
if (settled) return;
|
|
1516
|
-
if (sub.closed || sub.frozen || this.disposed) {
|
|
1517
|
-
sub.feed.abort(epoch);
|
|
1518
|
-
settle(FAILED_RESEED_RESULT);
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
if (!reply.ok) {
|
|
1522
|
-
if (!captureSucceeded) retireMarker();
|
|
1523
|
-
sub.feed.abort(epoch);
|
|
1524
|
-
settle(FAILED_RESEED_RESULT);
|
|
1525
|
-
return;
|
|
1526
|
-
}
|
|
1527
|
-
// Native/cursor replies can precede the paired layout/border reply.
|
|
1528
|
-
// Never publish that candidate against old authority, nor replay held
|
|
1529
|
-
// output over its snapshot. Recapture after the latest layout releases,
|
|
1530
|
-
// retaining this recipe's original deadline and one queue lease.
|
|
1531
|
-
const windowId = sub.pane.windowRuntimeId;
|
|
1532
|
-
const probe = parseCursorProbe(reply.lines[0] ?? "");
|
|
1533
|
-
const validCaptureGeometry =
|
|
1534
|
-
probe &&
|
|
1535
|
-
Number.isSafeInteger(probe.x) &&
|
|
1536
|
-
Number.isSafeInteger(probe.y) &&
|
|
1537
|
-
probe.y < probe.rows &&
|
|
1538
|
-
(!capturedNativeSize ||
|
|
1539
|
-
(capturedNativeSize.cols === probe.cols && capturedNativeSize.rows === probe.rows));
|
|
1540
|
-
const layoutSize = validCaptureGeometry ? this.layoutCaptureSizeFor(sub) : null;
|
|
1541
|
-
const pendingLayout = windowId !== null && this.pendingLayoutOutput.has(windowId);
|
|
1542
|
-
const aheadOfLayout =
|
|
1543
|
-
layoutSize && probe && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows);
|
|
1544
|
-
if (windowId && layoutSize && validCaptureGeometry && (pendingLayout || aheadOfLayout)) {
|
|
1545
|
-
sub.feed.abort(epoch);
|
|
1546
|
-
captureLines = null;
|
|
1547
|
-
const waitingAfterSyncOrdinal = this.syncOrdinal;
|
|
1548
|
-
resumeLayoutCapture = (completedSyncOrdinal) => {
|
|
1549
|
-
if (settled) return;
|
|
1550
|
-
if (this.recoveryNowMs() >= deadlineAt) {
|
|
1551
|
-
settle(FAILED_RESEED_RESULT);
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
const current = this.layoutCaptureSizeFor(sub);
|
|
1555
|
-
// Only a successful truth sync started after this wait is a causal
|
|
1556
|
-
// barrier, including resize-back to identical geometry. An older
|
|
1557
|
-
// in-flight sync and cached layout emissions cannot qualify it.
|
|
1558
|
-
if (!current) return;
|
|
1559
|
-
if (completedSyncOrdinal !== undefined) {
|
|
1560
|
-
if (completedSyncOrdinal <= waitingAfterSyncOrdinal) return;
|
|
1561
|
-
} else if (
|
|
1562
|
-
!pendingLayout &&
|
|
1563
|
-
current.cols === layoutSize.cols &&
|
|
1564
|
-
current.rows === layoutSize.rows
|
|
1565
|
-
)
|
|
1566
|
-
return;
|
|
1567
|
-
settled = true;
|
|
1568
|
-
clearLayoutWait();
|
|
1569
|
-
cancelDeadline?.();
|
|
1570
|
-
lease.cancelRecipe = null;
|
|
1571
|
-
this.reseed(lease, onSettled, deferPublish, deadlineAt);
|
|
1572
|
-
};
|
|
1573
|
-
sub.resumeLayoutCapture = resumeLayoutCapture;
|
|
1574
|
-
if (!pendingLayout) this.scheduleSync();
|
|
1575
|
-
return;
|
|
1576
|
-
}
|
|
1577
|
-
const cursorLine = reply.lines[0] ?? "";
|
|
1578
|
-
const historySize = Number(cursorLine.trim().split(/\s+/)[14]);
|
|
1579
|
-
if (Number.isSafeInteger(historySize) && historySize >= 0)
|
|
1580
|
-
sub.pane.historySize = historySize;
|
|
1581
|
-
const fallbackSize = this.layoutSizeFor(runtime);
|
|
1582
|
-
this.observeScrollOnClear(sub.pane, cursorLine);
|
|
1583
|
-
const events = sub.feed.cursorReply(epoch, cursorLine, fallbackSize);
|
|
1584
|
-
let published = false;
|
|
1585
|
-
const publish = (): boolean => {
|
|
1586
|
-
if (published) return true;
|
|
1587
|
-
if (sub.closed || sub.frozen || this.disposed) return false;
|
|
1588
|
-
published = true;
|
|
1589
|
-
for (const event of events) sub.onEvent(event);
|
|
1590
|
-
return true;
|
|
1591
|
-
};
|
|
1592
|
-
const ok = events.length > 0 && !sub.closed && captureLines !== null;
|
|
1593
|
-
const result = {
|
|
1594
|
-
ok,
|
|
1595
|
-
fingerprint: ok ? snapshotFingerprint(captureLines!, cursorLine, fallbackSize) : null,
|
|
1596
|
-
publish,
|
|
1597
|
-
hold: () => sub.feed.quarantine(epoch),
|
|
1598
|
-
} satisfies ReseedResult;
|
|
1599
|
-
if (!deferPublish) publish();
|
|
1600
|
-
settle(result);
|
|
1601
|
-
},
|
|
1602
|
-
);
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
1424
|
private reseedPlain(sub: SubRecord, resumeReason: "requested" | null = null): void {
|
|
1606
1425
|
if (sub.closed || sub.frozen || this.disposed) return;
|
|
1607
|
-
|
|
1608
|
-
// waiting recipe already owns that request and its absolute deadline.
|
|
1609
|
-
if (sub.resumeLayoutCapture) return;
|
|
1610
|
-
if (this.plainReseedActive?.sub === sub) sub.cancelCapture?.();
|
|
1611
|
-
if (!this.plainReseedQueue.has(sub) && this.plainReseedQueue.size >= MAX_QUEUED_PLAIN_RESEEDS) {
|
|
1612
|
-
this.failPlainReseed(sub);
|
|
1613
|
-
return;
|
|
1614
|
-
}
|
|
1615
|
-
this.plainReseedQueue.set(sub, resumeReason);
|
|
1616
|
-
if (this.plainReseedActive?.sub !== sub) {
|
|
1617
|
-
// Output before this queued recipe starts is already in its future
|
|
1618
|
-
// capture. Do not publish it using the prior geometry in the meantime.
|
|
1619
|
-
sub.feed.beginReseed();
|
|
1620
|
-
sub.cancelCapture = () => {
|
|
1621
|
-
this.plainReseedQueue.delete(sub);
|
|
1622
|
-
sub.cancelCapture = null;
|
|
1623
|
-
sub.feed.abortCurrent();
|
|
1624
|
-
};
|
|
1625
|
-
}
|
|
1626
|
-
this.drainPlainReseeds();
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
private failPlainReseed(sub: SubRecord): void {
|
|
1630
|
-
if (
|
|
1631
|
-
sub.closed ||
|
|
1632
|
-
sub.frozen ||
|
|
1633
|
-
this.disposed ||
|
|
1634
|
-
this.recoveries.has(sub.pane.runtimeId) ||
|
|
1635
|
-
this.panesByRuntime.get(sub.pane.runtimeId) !== sub.pane
|
|
1636
|
-
)
|
|
1637
|
-
return;
|
|
1638
|
-
this.beginLocalOverflowRecovery(sub.pane);
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
private drainPlainReseeds(): void {
|
|
1642
|
-
if (this.plainReseedActive || this.disposed) return;
|
|
1643
|
-
const next = this.plainReseedQueue.entries().next().value;
|
|
1644
|
-
if (!next) return;
|
|
1645
|
-
const [sub, resumeReason] = next;
|
|
1646
|
-
this.plainReseedQueue.delete(sub);
|
|
1647
|
-
if (sub.closed || sub.frozen) {
|
|
1648
|
-
this.drainPlainReseeds();
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
sub.cancelCapture?.();
|
|
1652
|
-
const lease: PlainReseedLease = { sub, cancelRecipe: null };
|
|
1653
|
-
this.plainReseedActive = lease;
|
|
1654
|
-
// The queue lease outlives individual native capability probes. Retrying
|
|
1655
|
-
// or falling back replaces only cancelRecipe, never this queue owner.
|
|
1656
|
-
sub.cancelCapture = () => {
|
|
1657
|
-
if (this.plainReseedActive !== lease) return;
|
|
1658
|
-
lease.cancelRecipe?.();
|
|
1659
|
-
sub.cancelCapture = null;
|
|
1660
|
-
this.plainReseedActive = null;
|
|
1661
|
-
this.drainPlainReseeds();
|
|
1662
|
-
};
|
|
1663
|
-
// Only the recipe actually submitted to the control FIFO owns a five
|
|
1664
|
-
// second deadline. A large sibling projection cannot consume the waiting
|
|
1665
|
-
// panes' entire capture budgets before their commands have been written.
|
|
1666
|
-
this.reseed(
|
|
1667
|
-
lease,
|
|
1668
|
-
(result) => {
|
|
1669
|
-
if (this.plainReseedActive !== lease) return;
|
|
1670
|
-
sub.cancelCapture = null;
|
|
1671
|
-
this.plainReseedActive = null;
|
|
1672
|
-
if (!result.ok) {
|
|
1673
|
-
// A stalled FIFO cannot make progress for queued recipes either. Retire
|
|
1674
|
-
// them together instead of granting each another wait behind that slot.
|
|
1675
|
-
const waiting = [...this.plainReseedQueue.keys()];
|
|
1676
|
-
this.plainReseedQueue.clear();
|
|
1677
|
-
for (const queued of waiting) queued.cancelCapture?.();
|
|
1678
|
-
this.failPlainReseed(sub);
|
|
1679
|
-
for (const queued of waiting) this.failPlainReseed(queued);
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
// Admit the next recipe before publishing this potentially large seed.
|
|
1683
|
-
this.drainPlainReseeds();
|
|
1684
|
-
result.publish();
|
|
1685
|
-
if (resumeReason && !sub.closed && !sub.frozen)
|
|
1686
|
-
sub.onEvent({ type: "flow", state: "resumed", reason: resumeReason });
|
|
1687
|
-
},
|
|
1688
|
-
true,
|
|
1689
|
-
);
|
|
1426
|
+
this.beginRecovery(sub.pane, resumeReason ?? "requested");
|
|
1690
1427
|
}
|
|
1691
1428
|
|
|
1692
1429
|
private retireInternalReadMarker(runtime: string, marker: string): void {
|
|
1693
1430
|
if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
|
|
1694
1431
|
throw new TypeError("internal read cleanup requires a runtime pane id");
|
|
1695
1432
|
retireInternalReadOperation(marker, runtime);
|
|
1433
|
+
this.clearInternalReadMarkerOption(runtime, marker);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
private clearInternalReadMarkerOption(runtime: string, marker: string): void {
|
|
1437
|
+
if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtime))
|
|
1438
|
+
throw new TypeError("internal read cleanup requires a runtime pane id");
|
|
1439
|
+
// Success must keep the in-memory proof redeemable by a delayed observer.
|
|
1440
|
+
// The server option must still be removed when no observer hook is installed.
|
|
1696
1441
|
// Pane capture phases overlap under cancellation. Clear only the exact
|
|
1697
|
-
//
|
|
1442
|
+
// owned marker so a late A callback cannot erase the newer B authority.
|
|
1698
1443
|
// Both selected branches must emit one reply in addition to if-shell's
|
|
1699
1444
|
// own reply. An empty false branch emits none and shifts the control FIFO
|
|
1700
1445
|
// whenever the true branch runs during capture cancellation.
|
|
@@ -1750,6 +1495,12 @@ export class SessionChannel {
|
|
|
1750
1495
|
if (sub.frozen || sub.closed) return;
|
|
1751
1496
|
sub.frozen = true;
|
|
1752
1497
|
sub.cancelCapture?.();
|
|
1498
|
+
const activeRecovery = this.recoveries.get(sub.pane.runtimeId);
|
|
1499
|
+
if (
|
|
1500
|
+
activeRecovery &&
|
|
1501
|
+
[...sub.pane.subs].some((candidate) => !candidate.closed && !candidate.frozen)
|
|
1502
|
+
)
|
|
1503
|
+
this.retrySnapshot(activeRecovery);
|
|
1753
1504
|
sub.onEvent({ type: "flow", state: "paused", reason: "requested" });
|
|
1754
1505
|
const pane = sub.pane;
|
|
1755
1506
|
const allFrozen = [...pane.subs].every((candidate) => candidate.frozen || candidate.closed);
|
|
@@ -1838,39 +1589,46 @@ export class SessionChannel {
|
|
|
1838
1589
|
const recovery = this.recoveries.get(runtime);
|
|
1839
1590
|
if (!recovery) return;
|
|
1840
1591
|
recovery.retired = true;
|
|
1841
|
-
|
|
1592
|
+
this.recoveries.delete(runtime);
|
|
1593
|
+
this.snapshotQueue.delete(runtime);
|
|
1842
1594
|
recovery.cancelCommandDeadline?.();
|
|
1843
1595
|
recovery.cancelNoProgressDeadline?.();
|
|
1844
1596
|
recovery.cancelAbsoluteDeadline?.();
|
|
1845
|
-
if (recovery.atomicCollectorNonce)
|
|
1846
|
-
this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
|
|
1847
|
-
recovery.atomicCollectorNonce = null;
|
|
1848
|
-
this.recoveries.delete(runtime);
|
|
1849
|
-
this.retireContinueNotificationOwner(recovery);
|
|
1850
1597
|
const pane = this.panesByRuntime.get(runtime);
|
|
1851
1598
|
if (pane?.incarnation === recovery.paneIncarnation)
|
|
1852
1599
|
for (const sub of pane.subs) sub.feed.abortCurrent();
|
|
1600
|
+
if (recovery.lease) this.retireSnapshotLease(recovery.lease);
|
|
1601
|
+
else if (recovery.atomicCollectorNonce)
|
|
1602
|
+
this.io.retireAtomicPaneSnapshotCollector?.(recovery.atomicCollectorNonce, "retired");
|
|
1603
|
+
recovery.atomicCollectorNonce = null;
|
|
1853
1604
|
}
|
|
1854
1605
|
|
|
1855
1606
|
private beginRecovery(pane: PaneRecord, reason: "backpressure" | "requested"): void {
|
|
1856
|
-
|
|
1857
|
-
const
|
|
1858
|
-
|
|
1607
|
+
if (this.disposed) return;
|
|
1608
|
+
const current = this.recoveries.get(pane.runtimeId);
|
|
1609
|
+
if (current && !current.retired) {
|
|
1610
|
+
if (this.snapshotQueue.get(pane.runtimeId) === current) return;
|
|
1611
|
+
this.retrySnapshot(current);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (this.snapshotQueue.size >= MAX_QUEUED_SNAPSHOTS) {
|
|
1615
|
+
for (const sub of pane.subs) {
|
|
1616
|
+
sub.feed.abortCurrent();
|
|
1617
|
+
if (!sub.closed && !sub.frozen)
|
|
1618
|
+
sub.onEvent({ type: "fault", reason: "native-recovery-failed" });
|
|
1619
|
+
}
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1859
1622
|
const recovery: RecoveryRecord = {
|
|
1860
1623
|
ordinal: ++this.recoveryOrdinal,
|
|
1861
|
-
runtimeId:
|
|
1624
|
+
runtimeId: pane.runtimeId,
|
|
1862
1625
|
paneIncarnation: pane.incarnation,
|
|
1863
1626
|
reason,
|
|
1864
1627
|
startedAtMs: this.recoveryNowMs(),
|
|
1865
1628
|
retired: false,
|
|
1866
|
-
|
|
1867
|
-
continueNotify: false,
|
|
1868
|
-
stage: "continue",
|
|
1629
|
+
stage: "queued",
|
|
1869
1630
|
attempts: 0,
|
|
1870
1631
|
reseedOrdinal: 0,
|
|
1871
|
-
outputOrdinal: this.outputOrdinals.get(runtime) ?? 0,
|
|
1872
|
-
candidateFingerprint: null,
|
|
1873
|
-
confirmationFingerprint: null,
|
|
1874
1632
|
confirmationOrdinal: 0,
|
|
1875
1633
|
atomicCollectorNonce: null,
|
|
1876
1634
|
collectorStarted: false,
|
|
@@ -1881,99 +1639,375 @@ export class SessionChannel {
|
|
|
1881
1639
|
collectorStatusObserved: false,
|
|
1882
1640
|
collectorObserverEmissionObserved: false,
|
|
1883
1641
|
collectorFailureReason: null,
|
|
1884
|
-
cancelQuiet: null,
|
|
1885
1642
|
cancelCommandDeadline: null,
|
|
1886
1643
|
cancelNoProgressDeadline: null,
|
|
1887
1644
|
cancelAbsoluteDeadline: null,
|
|
1888
1645
|
};
|
|
1889
|
-
this.recoveries.set(
|
|
1890
|
-
|
|
1891
|
-
|
|
1646
|
+
this.recoveries.set(pane.runtimeId, recovery);
|
|
1647
|
+
this.snapshotQueue.set(pane.runtimeId, recovery);
|
|
1648
|
+
this.drainSnapshots();
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
private beginLocalOverflowRecovery(pane: PaneRecord): void {
|
|
1652
|
+
this.beginRecovery(pane, "backpressure");
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private snapshotContext(pane: PaneRecord): StockPaneSnapshotContext {
|
|
1656
|
+
return {
|
|
1657
|
+
paneId: pane.runtimeId,
|
|
1658
|
+
incarnation: pane.incarnation,
|
|
1659
|
+
layoutGeneration: pane.snapshotLayoutGeneration,
|
|
1660
|
+
participants: [...pane.subs].filter((sub) => !sub.closed && !sub.frozen),
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private snapshotLeaseCurrent(lease: SnapshotLease): boolean {
|
|
1665
|
+
return (
|
|
1666
|
+
this.snapshotActive === lease && !lease.retired && this.recoveryPane(lease.recovery) !== null
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private drainSnapshots(): void {
|
|
1671
|
+
if (this.snapshotActive || this.disposed) return;
|
|
1672
|
+
for (const [runtime, recovery] of this.snapshotQueue) {
|
|
1673
|
+
if (
|
|
1674
|
+
recovery.waitForSyncAfter !== undefined &&
|
|
1675
|
+
this.completedSyncOrdinal <= recovery.waitForSyncAfter
|
|
1676
|
+
)
|
|
1677
|
+
continue;
|
|
1678
|
+
this.snapshotQueue.delete(runtime);
|
|
1679
|
+
const pane = this.recoveryPane(recovery);
|
|
1680
|
+
if (!pane || ![...pane.subs].some((sub) => !sub.closed && !sub.frozen)) {
|
|
1681
|
+
this.cancelRecovery(runtime);
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
1685
|
+
this.failRecovery(recovery, "attempts-exhausted");
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
const lease: SnapshotLease = {
|
|
1689
|
+
recovery,
|
|
1690
|
+
retired: false,
|
|
1691
|
+
wireNonce: null,
|
|
1692
|
+
retry: false,
|
|
1693
|
+
fencePending: false,
|
|
1694
|
+
cancelFence: null,
|
|
1695
|
+
};
|
|
1696
|
+
this.snapshotActive = lease;
|
|
1697
|
+
recovery.lease = lease;
|
|
1698
|
+
if (!recovery.cancelAbsoluteDeadline) {
|
|
1699
|
+
recovery.startedAtMs = this.recoveryNowMs();
|
|
1700
|
+
this.beginRecoveryConvergence(recovery);
|
|
1701
|
+
} else this.noteRecoveryProgress(recovery);
|
|
1702
|
+
for (const sub of pane.subs) if (!sub.closed && !sub.frozen) sub.feed.abortCurrent();
|
|
1703
|
+
const target = this.nativeRecoveryTarget(pane);
|
|
1704
|
+
if (
|
|
1705
|
+
recovery.nativeOwner &&
|
|
1706
|
+
(target?.serverEpoch !== recovery.nativeOwner.serverEpoch ||
|
|
1707
|
+
target?.paneBirthId !== recovery.nativeOwner.paneBirthId)
|
|
1708
|
+
) {
|
|
1709
|
+
this.failRecovery(recovery, "command-error");
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (target) {
|
|
1713
|
+
recovery.nativeOwner ??= target;
|
|
1714
|
+
recovery.stock = undefined;
|
|
1715
|
+
this.recoverNativeAtomic(pane, recovery, target);
|
|
1716
|
+
} else this.startStockSnapshot(pane, recovery, lease);
|
|
1717
|
+
return;
|
|
1892
1718
|
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
private releaseSnapshotLease(lease: SnapshotLease): void {
|
|
1722
|
+
if (this.snapshotActive !== lease || lease.wireNonce || lease.fencePending) return;
|
|
1723
|
+
lease.cancelFence?.();
|
|
1724
|
+
lease.cancelFence = null;
|
|
1725
|
+
const pane = this.panesByRuntime.get(lease.recovery.runtimeId);
|
|
1726
|
+
if (
|
|
1727
|
+
lease.ownsPause &&
|
|
1728
|
+
pane?.incarnation === lease.recovery.paneIncarnation &&
|
|
1729
|
+
pane.subs.size === 0 &&
|
|
1730
|
+
!this.ledger.isRequested(pane.runtimeId)
|
|
1731
|
+
) {
|
|
1732
|
+
this.io.commandInline(`refresh-client -A '${pane.runtimeId}:continue'`, () => {});
|
|
1733
|
+
this.ledger.noteContinued(pane.runtimeId);
|
|
1734
|
+
}
|
|
1735
|
+
this.snapshotActive = null;
|
|
1736
|
+
if (lease.recovery.lease === lease) lease.recovery.lease = undefined;
|
|
1737
|
+
if (lease.retry && this.recoveryPane(lease.recovery)) {
|
|
1738
|
+
lease.recovery.stage = "queued";
|
|
1739
|
+
this.snapshotQueue.set(lease.recovery.runtimeId, lease.recovery);
|
|
1740
|
+
}
|
|
1741
|
+
this.drainSnapshots();
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
private retireSnapshotLease(lease: SnapshotLease, retry = false): void {
|
|
1745
|
+
if (this.snapshotActive !== lease) return;
|
|
1746
|
+
lease.retired = true;
|
|
1747
|
+
if (this.disposed) {
|
|
1748
|
+
lease.cancelFence?.();
|
|
1749
|
+
lease.cancelFence = null;
|
|
1750
|
+
this.snapshotActive = null;
|
|
1751
|
+
this.snapshotQueue.clear();
|
|
1898
1752
|
return;
|
|
1899
1753
|
}
|
|
1900
|
-
|
|
1901
|
-
recovery.
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (
|
|
1907
|
-
this.
|
|
1754
|
+
lease.retry ||= retry;
|
|
1755
|
+
lease.recovery.stock?.invalidate();
|
|
1756
|
+
lease.cleanup?.();
|
|
1757
|
+
lease.cleanup = undefined;
|
|
1758
|
+
lease.recovery.cancelCommandDeadline?.();
|
|
1759
|
+
lease.recovery.cancelCommandDeadline = null;
|
|
1760
|
+
if (lease.wireNonce) {
|
|
1761
|
+
this.io.retireAtomicPaneSnapshotCollector?.(lease.wireNonce, "retired");
|
|
1908
1762
|
return;
|
|
1909
1763
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1764
|
+
if (lease.fencePending) return;
|
|
1765
|
+
// Native wrapper/setup commands still own ordinary FIFO slots. A separate
|
|
1766
|
+
// bounded command proves they drained before another raw hook is admitted.
|
|
1767
|
+
lease.fencePending = true;
|
|
1768
|
+
const token = `tmux-ide-snapshot-admission:${randomBytes(24).toString("hex")}`;
|
|
1769
|
+
const failed = () => {
|
|
1770
|
+
if (this.snapshotActive !== lease) return;
|
|
1771
|
+
lease.retry = false;
|
|
1772
|
+
lease.cancelFence?.();
|
|
1773
|
+
void this.io.dispose();
|
|
1774
|
+
};
|
|
1775
|
+
lease.cancelFence = this.scheduleRecovery(failed, RECOVERY_ABSOLUTE_DEADLINE_MS);
|
|
1776
|
+
const done = (reply: ControlReply) => {
|
|
1777
|
+
if (this.snapshotActive !== lease) return;
|
|
1778
|
+
if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== token) {
|
|
1779
|
+
failed();
|
|
1922
1780
|
return;
|
|
1923
1781
|
}
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
this.
|
|
1929
|
-
|
|
1930
|
-
|
|
1782
|
+
lease.fencePending = false;
|
|
1783
|
+
this.releaseSnapshotLease(lease);
|
|
1784
|
+
};
|
|
1785
|
+
if (this.io.commandBoundedInline)
|
|
1786
|
+
this.io.commandBoundedInline(
|
|
1787
|
+
`display-message -p -l ${token}`,
|
|
1788
|
+
{ maxBytes: 256, maxLines: 1 },
|
|
1789
|
+
done,
|
|
1790
|
+
);
|
|
1791
|
+
else this.io.commandInline(`display-message -p -l ${token}`, done);
|
|
1931
1792
|
}
|
|
1932
1793
|
|
|
1933
|
-
private
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
this.
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1794
|
+
private awaitSnapshotLayout(recovery: RecoveryRecord): void {
|
|
1795
|
+
recovery.cancelNoProgressDeadline?.();
|
|
1796
|
+
recovery.cancelNoProgressDeadline = null;
|
|
1797
|
+
recovery.waitForSyncAfter = this.syncOrdinal;
|
|
1798
|
+
this.scheduleSync();
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
private retrySnapshot(recovery: RecoveryRecord): void {
|
|
1802
|
+
const pane = this.recoveryPane(recovery);
|
|
1803
|
+
if (!pane) return;
|
|
1804
|
+
for (const sub of pane.subs) if (!sub.closed && !sub.frozen) sub.feed.abortCurrent();
|
|
1805
|
+
recovery.stock?.invalidate();
|
|
1806
|
+
if (
|
|
1807
|
+
recovery.attempts >= RECOVERY_MAX_ATTEMPTS ||
|
|
1808
|
+
(recovery.cancelAbsoluteDeadline &&
|
|
1809
|
+
this.recoveryNowMs() - recovery.startedAtMs >= RECOVERY_ABSOLUTE_DEADLINE_MS)
|
|
1810
|
+
) {
|
|
1811
|
+
this.failRecovery(recovery, "attempts-exhausted");
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
if (recovery.lease) this.retireSnapshotLease(recovery.lease, true);
|
|
1815
|
+
else {
|
|
1816
|
+
recovery.stage = "queued";
|
|
1817
|
+
this.snapshotQueue.set(recovery.runtimeId, recovery);
|
|
1818
|
+
this.drainSnapshots();
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
private startStockSnapshot(
|
|
1823
|
+
pane: PaneRecord,
|
|
1824
|
+
recovery: RecoveryRecord,
|
|
1825
|
+
lease: SnapshotLease,
|
|
1826
|
+
): void {
|
|
1827
|
+
if (!this.io.armAtomicPaneSnapshotCollector || !this.io.retireAtomicPaneSnapshotCollector) {
|
|
1828
|
+
this.failRecovery(recovery, "command-error");
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (
|
|
1832
|
+
!this.nativeBootstrapConfirmed &&
|
|
1833
|
+
!this.nativeBootstrapUnavailable &&
|
|
1834
|
+
[...pane.subs].some((sub) => !sub.closed && !sub.frozen && sub.nativeBootstrap)
|
|
1835
|
+
) {
|
|
1836
|
+
// Negotiate the live server before placing -R in a NOHOOKS transaction:
|
|
1837
|
+
// an unsupported flag aborts that hook before its completion sentinel.
|
|
1838
|
+
recovery.nativeProbeAttempts = (recovery.nativeProbeAttempts ?? 0) + 1;
|
|
1839
|
+
let marker: string | null = null;
|
|
1840
|
+
let probeSettled = false;
|
|
1841
|
+
recovery.cancelCommandDeadline = this.scheduleRecovery(() => {
|
|
1842
|
+
if (probeSettled || !this.snapshotLeaseCurrent(lease)) return;
|
|
1843
|
+
probeSettled = true;
|
|
1844
|
+
if ((recovery.nativeProbeAttempts ?? 0) < 2) this.retrySnapshot(recovery);
|
|
1845
|
+
else this.failRecovery(recovery, "command-timeout");
|
|
1846
|
+
}, RECOVERY_COMMAND_DEADLINE_MS);
|
|
1847
|
+
this.captureWithViewer(
|
|
1848
|
+
pane.runtimeId,
|
|
1849
|
+
["-R", "-S", "-"],
|
|
1850
|
+
(value) => {
|
|
1851
|
+
marker = value;
|
|
1852
|
+
},
|
|
1853
|
+
(reply) => {
|
|
1854
|
+
if (marker) this.retireInternalReadMarker(pane.runtimeId, marker);
|
|
1855
|
+
if (probeSettled || !this.snapshotLeaseCurrent(lease)) return;
|
|
1856
|
+
probeSettled = true;
|
|
1857
|
+
recovery.cancelCommandDeadline?.();
|
|
1858
|
+
recovery.cancelCommandDeadline = null;
|
|
1859
|
+
const native = reply.ok ? decodeNativeGridCapture(reply.lines.join("\n")) : null;
|
|
1860
|
+
if (native && isNativeBootstrapCapture(native)) this.nativeBootstrapConfirmed = true;
|
|
1861
|
+
else if (nativeBootstrapUnsupported(reply.ok, reply.lines, native)) {
|
|
1862
|
+
this.nativeBootstrapUnavailable = true;
|
|
1863
|
+
for (const current of this.panesByRuntime.values())
|
|
1864
|
+
for (const sub of current.subs) sub.nativeBootstrap = false;
|
|
1865
|
+
} else {
|
|
1866
|
+
if ((recovery.nativeProbeAttempts ?? 0) < 2) this.retrySnapshot(recovery);
|
|
1867
|
+
else this.failRecovery(recovery, "command-error");
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
this.startStockSnapshot(pane, recovery, lease);
|
|
1871
|
+
},
|
|
1872
|
+
{ maxBytes: RECOVERY_CAPTURE_MAX_BYTES, maxLines: RECOVERY_CAPTURE_MAX_LINES },
|
|
1873
|
+
);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
recovery.attempts += 1;
|
|
1877
|
+
recovery.stage = "stock-pause";
|
|
1878
|
+
recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
|
|
1879
|
+
// These rows were received before this pause and belong to the new seed,
|
|
1880
|
+
// never to a delayed old-geometry replay after it.
|
|
1881
|
+
const pending = pane.windowRuntimeId
|
|
1882
|
+
? this.pendingLayoutOutput.get(pane.windowRuntimeId)
|
|
1883
|
+
: null;
|
|
1884
|
+
if (pending) {
|
|
1885
|
+
pending.records = pending.records.filter((record) => record.pane !== pane.runtimeId);
|
|
1886
|
+
pending.bytes = pending.records.reduce((bytes, record) => bytes + record.data.byteLength, 0);
|
|
1971
1887
|
}
|
|
1972
1888
|
this.observeRecovery(pane, recovery, "pause");
|
|
1973
|
-
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1889
|
+
const nonce = randomBytes(24).toString("hex");
|
|
1890
|
+
const hook = `@tmux_ide_pause_${nonce}`;
|
|
1891
|
+
const body =
|
|
1892
|
+
`display-message -p -l '%tmux-ide-atomic-v1 ${nonce} start'` +
|
|
1893
|
+
` ; refresh-client -A '${pane.runtimeId}:pause'` +
|
|
1894
|
+
` ; display-message -p -l '%tmux-ide-atomic-v1 ${nonce} complete'`;
|
|
1895
|
+
let settled = false;
|
|
1896
|
+
let accepted = false;
|
|
1897
|
+
const cleanup = () =>
|
|
1898
|
+
this.io.commandListInline(
|
|
1899
|
+
`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
|
|
1900
|
+
`${tmuxSingleQuote(`set-option -pu -t ${pane.runtimeId} ${hook}`)} ` +
|
|
1901
|
+
`${tmuxSingleQuote("display-message -p -l pause-cleanup-skipped")}`,
|
|
1902
|
+
2,
|
|
1903
|
+
1,
|
|
1904
|
+
() => {},
|
|
1905
|
+
);
|
|
1906
|
+
lease.cleanup = cleanup;
|
|
1907
|
+
const failed = () => {
|
|
1908
|
+
if (settled) return;
|
|
1909
|
+
settled = true;
|
|
1910
|
+
cleanup();
|
|
1911
|
+
if (this.snapshotLeaseCurrent(lease)) this.retrySnapshot(recovery);
|
|
1912
|
+
};
|
|
1913
|
+
recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
|
|
1914
|
+
this.input.flush();
|
|
1915
|
+
this.io.commandInline(
|
|
1916
|
+
`set-option -po -t ${pane.runtimeId} ${hook} ${tmuxSingleQuote(body)}`,
|
|
1917
|
+
(reply) => {
|
|
1918
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
1919
|
+
cleanup();
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (!reply.ok) {
|
|
1923
|
+
failed();
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
lease.wireNonce = nonce;
|
|
1927
|
+
const armed = this.io.armAtomicPaneSnapshotCollector!(
|
|
1928
|
+
{
|
|
1929
|
+
nonce,
|
|
1930
|
+
kind: "pause",
|
|
1931
|
+
runtimePaneId: pane.runtimeId,
|
|
1932
|
+
maxCaptureBytes: 1024,
|
|
1933
|
+
maxCaptureLines: 16,
|
|
1934
|
+
maxCursorBytes: 128,
|
|
1935
|
+
observerCommandCount: 0,
|
|
1936
|
+
onSettled: (result) => {
|
|
1937
|
+
cleanup();
|
|
1938
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) return;
|
|
1939
|
+
if (!result.ok) {
|
|
1940
|
+
failed();
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
// The authenticated NOHOOKS body targets the same live pane as
|
|
1944
|
+
// set-hook -Rp. A successful pause is a no-op only when already
|
|
1945
|
+
// paused; its complete guard therefore fences that case too.
|
|
1946
|
+
accepted = recovery.stock!.observePause(this.snapshotContext(pane));
|
|
1947
|
+
if (!accepted) {
|
|
1948
|
+
failed();
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
lease.ownsPause = true;
|
|
1952
|
+
this.observedPaused.add(pane.runtimeId);
|
|
1953
|
+
this.ledger.notePause(pane.runtimeId);
|
|
1954
|
+
settled = true;
|
|
1955
|
+
recovery.cancelCommandDeadline?.();
|
|
1956
|
+
recovery.cancelCommandDeadline = null;
|
|
1957
|
+
},
|
|
1958
|
+
onDrained: (reason) => {
|
|
1959
|
+
if (lease.wireNonce === nonce) lease.wireNonce = null;
|
|
1960
|
+
if (reason === "channel-exit") {
|
|
1961
|
+
lease.retry = false;
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
if (!this.snapshotLeaseCurrent(lease) || !accepted) {
|
|
1965
|
+
this.releaseSnapshotLease(lease);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
recovery.stage = "stock-capture";
|
|
1969
|
+
this.reseedRecoverySubscribersAtomic(
|
|
1970
|
+
pane,
|
|
1971
|
+
recovery,
|
|
1972
|
+
(result) => {
|
|
1973
|
+
if (!this.snapshotLeaseCurrent(lease)) return;
|
|
1974
|
+
if (!result.ok || !result.publish()) this.retrySnapshot(recovery);
|
|
1975
|
+
else this.convergeRecovery(pane, recovery);
|
|
1976
|
+
},
|
|
1977
|
+
true,
|
|
1978
|
+
);
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
Math.max(
|
|
1982
|
+
1,
|
|
1983
|
+
Math.floor(
|
|
1984
|
+
RECOVERY_ABSOLUTE_DEADLINE_MS - (this.recoveryNowMs() - recovery.startedAtMs),
|
|
1985
|
+
),
|
|
1986
|
+
),
|
|
1987
|
+
);
|
|
1988
|
+
if (!armed) {
|
|
1989
|
+
lease.wireNonce = null;
|
|
1990
|
+
failed();
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
// Once queued, this pause may execute even if its local callback is
|
|
1994
|
+
// cancelled. Return the owned pause after drain when no viewer remains.
|
|
1995
|
+
lease.ownsPause = true;
|
|
1996
|
+
// The option value is immutable for this attempt; compare it before
|
|
1997
|
+
// dispatch so another actor cannot replace the owned NOHOOKS body.
|
|
1998
|
+
this.io.commandListInline(
|
|
1999
|
+
`if-shell -t ${pane.runtimeId} -F ${tmuxSingleQuote(`#{==:#{${hook}},${body}}`)} ` +
|
|
2000
|
+
`${tmuxSingleQuote(`set-hook -Rp -t ${pane.runtimeId} ${hook}`)} ` +
|
|
2001
|
+
`${tmuxSingleQuote("display-message -p -l pause-hook-rejected")}`,
|
|
2002
|
+
2,
|
|
2003
|
+
1,
|
|
2004
|
+
(result) => {
|
|
2005
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) return;
|
|
2006
|
+
if (!result.ok || result.lines.length) failed();
|
|
2007
|
+
},
|
|
2008
|
+
);
|
|
2009
|
+
},
|
|
2010
|
+
);
|
|
1977
2011
|
}
|
|
1978
2012
|
|
|
1979
2013
|
private nativeRecoveryTarget(
|
|
@@ -2000,7 +2034,8 @@ export class SessionChannel {
|
|
|
2000
2034
|
recovery: RecoveryRecord,
|
|
2001
2035
|
target: NativeAtomicSnapshotTarget & { representation: "native" | "dual" },
|
|
2002
2036
|
): void {
|
|
2003
|
-
|
|
2037
|
+
const lease = recovery.lease;
|
|
2038
|
+
if (!lease || !this.snapshotLeaseCurrent(lease) || this.recoveryPane(recovery) !== pane) return;
|
|
2004
2039
|
const currentTarget = this.nativeRecoveryTarget(pane);
|
|
2005
2040
|
if (
|
|
2006
2041
|
currentTarget?.serverEpoch !== target.serverEpoch ||
|
|
@@ -2024,6 +2059,7 @@ export class SessionChannel {
|
|
|
2024
2059
|
const live = [...pane.subs].filter((sub) => !sub.closed && !sub.frozen);
|
|
2025
2060
|
return (
|
|
2026
2061
|
this.recoveryPane(recovery) === pane &&
|
|
2062
|
+
this.snapshotLeaseCurrent(lease) &&
|
|
2027
2063
|
recovery.reseedOrdinal === ordinal &&
|
|
2028
2064
|
current?.serverEpoch === target.serverEpoch &&
|
|
2029
2065
|
current.paneBirthId === target.paneBirthId &&
|
|
@@ -2042,11 +2078,12 @@ export class SessionChannel {
|
|
|
2042
2078
|
for (const { sub } of participants) sub.feed.abortCurrent();
|
|
2043
2079
|
// A malformed/late reply may follow a committed resume. Every retry
|
|
2044
2080
|
// explicitly pauses first; never fall through to stock capture.
|
|
2045
|
-
this.
|
|
2081
|
+
this.retrySnapshot(recovery);
|
|
2046
2082
|
};
|
|
2047
2083
|
recovery.stage = "native-pause";
|
|
2048
2084
|
recovery.cancelCommandDeadline = this.scheduleRecovery(failed, RECOVERY_COMMAND_DEADLINE_MS);
|
|
2049
2085
|
this.input.flush();
|
|
2086
|
+
lease.ownsPause = true;
|
|
2050
2087
|
this.io.commandInline(`refresh-client -A '${pane.runtimeId}:pause'`, (pauseReply) => {
|
|
2051
2088
|
if (settled || !exact()) {
|
|
2052
2089
|
failed();
|
|
@@ -2090,12 +2127,27 @@ export class SessionChannel {
|
|
|
2090
2127
|
failed();
|
|
2091
2128
|
return;
|
|
2092
2129
|
}
|
|
2130
|
+
const probe = parseCursorProbe(result.cursorLine);
|
|
2131
|
+
const size = this.layoutCaptureSizeFor(participants[0]!.sub);
|
|
2132
|
+
if (
|
|
2133
|
+
!probe ||
|
|
2134
|
+
probe.y >= probe.rows ||
|
|
2135
|
+
result.capture.cols !== probe.cols ||
|
|
2136
|
+
result.capture.rows !== probe.rows
|
|
2137
|
+
) {
|
|
2138
|
+
failed();
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (
|
|
2142
|
+
(pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId)) ||
|
|
2143
|
+
(size && (size.cols !== probe.cols || size.rows !== probe.rows))
|
|
2144
|
+
) {
|
|
2145
|
+
this.awaitSnapshotLayout(recovery);
|
|
2146
|
+
failed();
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2093
2149
|
this.nativeBootstrapConfirmed = true;
|
|
2094
|
-
this.
|
|
2095
|
-
// The selected child reply owns its inline %continue. It adds no
|
|
2096
|
-
// asynchronous notification debt to the legacy continue queue.
|
|
2097
|
-
recovery.continueReply = true;
|
|
2098
|
-
recovery.continueNotify = true;
|
|
2150
|
+
this.observeSnapshotMetadata(pane, result.cursorLine);
|
|
2099
2151
|
const ansiLines = result.ansiCapture
|
|
2100
2152
|
? captureLinesFromAnsiBytes(result.ansiCapture)
|
|
2101
2153
|
: null;
|
|
@@ -2151,9 +2203,11 @@ export class SessionChannel {
|
|
|
2151
2203
|
private noteRecoveryProgress(recovery: RecoveryRecord): void {
|
|
2152
2204
|
if (!this.recoveryPane(recovery) || !recovery.cancelAbsoluteDeadline) return;
|
|
2153
2205
|
recovery.cancelNoProgressDeadline?.();
|
|
2154
|
-
|
|
2206
|
+
const cancel = this.scheduleRecovery(() => {
|
|
2207
|
+
if (recovery.cancelNoProgressDeadline !== cancel) return;
|
|
2155
2208
|
if (this.recoveryPane(recovery)) this.failRecovery(recovery, "no-progress");
|
|
2156
2209
|
}, RECOVERY_NO_PROGRESS_DEADLINE_MS);
|
|
2210
|
+
recovery.cancelNoProgressDeadline = cancel;
|
|
2157
2211
|
}
|
|
2158
2212
|
|
|
2159
2213
|
private noteAtomicCollectorProgress(
|
|
@@ -2186,155 +2240,18 @@ export class SessionChannel {
|
|
|
2186
2240
|
this.noteRecoveryProgress(recovery);
|
|
2187
2241
|
}
|
|
2188
2242
|
|
|
2189
|
-
private
|
|
2243
|
+
private reseedRecoverySubscribersAtomic(
|
|
2190
2244
|
pane: PaneRecord,
|
|
2191
2245
|
recovery: RecoveryRecord,
|
|
2192
2246
|
done: (result: ReseedResult) => void,
|
|
2193
|
-
deferPublish
|
|
2247
|
+
deferPublish: boolean,
|
|
2194
2248
|
): void {
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
}
|
|
2199
|
-
const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
2200
|
-
if (live.length === 0) {
|
|
2249
|
+
const lease = recovery.lease;
|
|
2250
|
+
const stock = recovery.stock;
|
|
2251
|
+
if (!lease || !stock || !this.snapshotLeaseCurrent(lease)) {
|
|
2201
2252
|
done(FAILED_RESEED_RESULT);
|
|
2202
2253
|
return;
|
|
2203
2254
|
}
|
|
2204
|
-
const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
|
|
2205
|
-
const nativeCapture =
|
|
2206
|
-
!this.nativeBootstrapUnavailable && participants.every(({ sub }) => sub.nativeBootstrap);
|
|
2207
|
-
const reseedOrdinal = ++recovery.reseedOrdinal;
|
|
2208
|
-
let settled = false;
|
|
2209
|
-
let captureSucceeded = false;
|
|
2210
|
-
let markerRetired = false;
|
|
2211
|
-
let captureLines: readonly string[] | null = null;
|
|
2212
|
-
// One pane authority capture is enough for every subscriber. Per-feed
|
|
2213
|
-
// epochs still independently fence delivery, while membership is frozen
|
|
2214
|
-
// across both FIFO replies so no subscriber can join half a snapshot.
|
|
2215
|
-
this.input.flush();
|
|
2216
|
-
const history = this.opts.historyLines ?? "";
|
|
2217
|
-
let internalReadMarker: string | null = null;
|
|
2218
|
-
const participantsExact = (): boolean => {
|
|
2219
|
-
if (
|
|
2220
|
-
this.recoveryPane(recovery) !== pane ||
|
|
2221
|
-
recovery.reseedOrdinal !== reseedOrdinal ||
|
|
2222
|
-
participants.some(
|
|
2223
|
-
({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub),
|
|
2224
|
-
)
|
|
2225
|
-
)
|
|
2226
|
-
return false;
|
|
2227
|
-
const current = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
2228
|
-
return (
|
|
2229
|
-
current.length === participants.length &&
|
|
2230
|
-
current.every((sub) => participants.some((participant) => participant.sub === sub))
|
|
2231
|
-
);
|
|
2232
|
-
};
|
|
2233
|
-
const retireMarker = (): void => {
|
|
2234
|
-
if (markerRetired) return;
|
|
2235
|
-
markerRetired = true;
|
|
2236
|
-
if (internalReadMarker) this.retireInternalReadMarker(pane.runtimeId, internalReadMarker);
|
|
2237
|
-
};
|
|
2238
|
-
const fail = (): void => {
|
|
2239
|
-
if (settled) return;
|
|
2240
|
-
settled = true;
|
|
2241
|
-
if (!captureSucceeded) retireMarker();
|
|
2242
|
-
for (const { sub } of participants) sub.feed.abortCurrent();
|
|
2243
|
-
done(FAILED_RESEED_RESULT);
|
|
2244
|
-
};
|
|
2245
|
-
this.captureWithViewer(
|
|
2246
|
-
pane.runtimeId,
|
|
2247
|
-
[...(nativeCapture ? ["-R"] : ["-e", "-J"]), "-S", `-${history}`],
|
|
2248
|
-
(marker) => {
|
|
2249
|
-
internalReadMarker = marker;
|
|
2250
|
-
},
|
|
2251
|
-
(reply) => {
|
|
2252
|
-
if (!reply.ok) {
|
|
2253
|
-
if (nativeCapture && nativeBootstrapUnsupported(false, reply.lines, null)) {
|
|
2254
|
-
this.nativeBootstrapUnavailable = true;
|
|
2255
|
-
for (const { sub } of participants) sub.nativeBootstrap = false;
|
|
2256
|
-
}
|
|
2257
|
-
fail();
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
captureSucceeded = true;
|
|
2261
|
-
if (!participantsExact()) {
|
|
2262
|
-
fail();
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
captureLines = Object.freeze([...reply.lines]);
|
|
2266
|
-
const native = nativeCapture ? decodeNativeGridCapture(captureLines.join("\n")) : null;
|
|
2267
|
-
if (nativeCapture && (!native || !isNativeBootstrapCapture(native))) {
|
|
2268
|
-
if (nativeBootstrapUnsupported(true, captureLines, native)) {
|
|
2269
|
-
this.nativeBootstrapUnavailable = true;
|
|
2270
|
-
for (const { sub } of participants) sub.nativeBootstrap = false;
|
|
2271
|
-
}
|
|
2272
|
-
fail();
|
|
2273
|
-
return;
|
|
2274
|
-
}
|
|
2275
|
-
if (native) this.nativeBootstrapConfirmed = true;
|
|
2276
|
-
for (const { sub, epoch } of participants) {
|
|
2277
|
-
if (native) sub.feed.captureNativeReply(epoch, native);
|
|
2278
|
-
else sub.feed.captureReply(epoch, captureLines);
|
|
2279
|
-
}
|
|
2280
|
-
},
|
|
2281
|
-
);
|
|
2282
|
-
this.io.commandInline(
|
|
2283
|
-
`display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"`,
|
|
2284
|
-
(reply) => {
|
|
2285
|
-
if (settled) return;
|
|
2286
|
-
if (!participantsExact() || captureLines === null || !reply.ok) {
|
|
2287
|
-
fail();
|
|
2288
|
-
return;
|
|
2289
|
-
}
|
|
2290
|
-
const cursorLine = reply.lines[0] ?? "";
|
|
2291
|
-
const fallbackSize = this.layoutSizeFor(pane.runtimeId);
|
|
2292
|
-
this.observeScrollOnClear(pane, cursorLine);
|
|
2293
|
-
const deliveries = participants.map(({ sub, epoch }) => ({
|
|
2294
|
-
sub,
|
|
2295
|
-
epoch,
|
|
2296
|
-
events: sub.feed
|
|
2297
|
-
.cursorReply(epoch, cursorLine, fallbackSize)
|
|
2298
|
-
.map((event) =>
|
|
2299
|
-
event.type === "seed" && sub.nativeBootstrap && !nativeCapture
|
|
2300
|
-
? { ...event, requiresNativeRecapture: true }
|
|
2301
|
-
: event,
|
|
2302
|
-
),
|
|
2303
|
-
}));
|
|
2304
|
-
if (!participantsExact() || deliveries.some(({ events }) => events.length === 0)) {
|
|
2305
|
-
fail();
|
|
2306
|
-
return;
|
|
2307
|
-
}
|
|
2308
|
-
let published = false;
|
|
2309
|
-
const publish = (): boolean => {
|
|
2310
|
-
if (published) return true;
|
|
2311
|
-
if (!participantsExact()) return false;
|
|
2312
|
-
published = true;
|
|
2313
|
-
for (const { sub, events } of deliveries) for (const event of events) sub.onEvent(event);
|
|
2314
|
-
return participantsExact();
|
|
2315
|
-
};
|
|
2316
|
-
if (!deferPublish && !publish()) {
|
|
2317
|
-
fail();
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
for (const { sub, epoch } of deliveries) sub.feed.quarantine(epoch);
|
|
2321
|
-
settled = true;
|
|
2322
|
-
done({
|
|
2323
|
-
ok: true,
|
|
2324
|
-
fingerprint: snapshotFingerprint(captureLines, cursorLine, fallbackSize),
|
|
2325
|
-
publish,
|
|
2326
|
-
hold: () => {},
|
|
2327
|
-
});
|
|
2328
|
-
},
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
private reseedRecoverySubscribersAtomic(
|
|
2333
|
-
pane: PaneRecord,
|
|
2334
|
-
recovery: RecoveryRecord,
|
|
2335
|
-
done: (result: ReseedResult) => void,
|
|
2336
|
-
deferPublish: boolean,
|
|
2337
|
-
): void {
|
|
2338
2255
|
const live = [...pane.subs].filter((sub) => !sub.frozen && !sub.closed);
|
|
2339
2256
|
if (live.length === 0) {
|
|
2340
2257
|
done(FAILED_RESEED_RESULT);
|
|
@@ -2342,7 +2259,8 @@ export class SessionChannel {
|
|
|
2342
2259
|
}
|
|
2343
2260
|
const participants = live.map((sub) => Object.freeze({ sub, epoch: sub.feed.beginReseed() }));
|
|
2344
2261
|
const nativeCapture =
|
|
2345
|
-
!this.nativeBootstrapUnavailable && participants.
|
|
2262
|
+
!this.nativeBootstrapUnavailable && participants.some(({ sub }) => sub.nativeBootstrap);
|
|
2263
|
+
const dualCapture = nativeCapture && participants.some(({ sub }) => !sub.nativeBootstrap);
|
|
2346
2264
|
const reseedOrdinal = ++recovery.reseedOrdinal;
|
|
2347
2265
|
const nonce = this.opts.generateAtomicHookNonce?.() ?? randomBytes(24).toString("hex");
|
|
2348
2266
|
if (!/^[0-9a-f]{32,128}$/u.test(nonce)) {
|
|
@@ -2366,6 +2284,7 @@ export class SessionChannel {
|
|
|
2366
2284
|
const participantsExact = (): boolean => {
|
|
2367
2285
|
if (
|
|
2368
2286
|
this.recoveryPane(recovery) !== pane ||
|
|
2287
|
+
!this.snapshotLeaseCurrent(lease) ||
|
|
2369
2288
|
recovery.reseedOrdinal !== reseedOrdinal ||
|
|
2370
2289
|
participants.some(
|
|
2371
2290
|
({ sub }) => sub.closed || sub.frozen || sub.pane !== pane || !pane.subs.has(sub),
|
|
@@ -2414,6 +2333,7 @@ export class SessionChannel {
|
|
|
2414
2333
|
() => {},
|
|
2415
2334
|
);
|
|
2416
2335
|
};
|
|
2336
|
+
lease.cleanup = cleanupHook;
|
|
2417
2337
|
const fail = (statusObserved = false): void => {
|
|
2418
2338
|
if (settled) return;
|
|
2419
2339
|
settled = true;
|
|
@@ -2435,25 +2355,30 @@ export class SessionChannel {
|
|
|
2435
2355
|
fail();
|
|
2436
2356
|
return;
|
|
2437
2357
|
}
|
|
2358
|
+
const observerRequired = this.opts.internalReadHookEmission !== undefined;
|
|
2438
2359
|
const safeObserver =
|
|
2439
2360
|
observer !== null &&
|
|
2440
2361
|
/^[A-Za-z0-9._-]{1,256}$/u.test(observer.bufferName) &&
|
|
2441
2362
|
/^[A-Za-z0-9._-]{1,256}$/u.test(observer.signalChannel) &&
|
|
2442
2363
|
/^[A-Za-z0-9%:._|-]{1,1024}$/u.test(observer.record);
|
|
2443
|
-
if (!safeObserver) {
|
|
2364
|
+
if (observerRequired && !safeObserver) {
|
|
2444
2365
|
fail();
|
|
2445
2366
|
return;
|
|
2446
2367
|
}
|
|
2447
2368
|
const sentinel = (kind: string): string =>
|
|
2448
2369
|
`display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
|
|
2449
|
-
const observerCommands =
|
|
2450
|
-
` ; ${boundedTmuxInteractionAppendCommand(observer!.bufferName, observer!.record)}` +
|
|
2451
|
-
|
|
2370
|
+
const observerCommands = safeObserver
|
|
2371
|
+
? ` ; ${boundedTmuxInteractionAppendCommand(observer!.bufferName, observer!.record)}` +
|
|
2372
|
+
` ; wait-for -S ${observer!.signalChannel}`
|
|
2373
|
+
: "";
|
|
2452
2374
|
const body =
|
|
2453
2375
|
`set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
|
|
2454
2376
|
` ; ${sentinel("start")}` +
|
|
2455
2377
|
` ; capture-pane -p ${nativeCapture ? "-R" : "-e -J"} -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId}` +
|
|
2456
2378
|
` ; ${sentinel("capture-end")}` +
|
|
2379
|
+
(dualCapture
|
|
2380
|
+
? ` ; capture-pane -p -e -J -S -${this.opts.historyLines ?? ""} -t ${pane.runtimeId} ; ${sentinel("ansi-capture-end")}`
|
|
2381
|
+
: "") +
|
|
2457
2382
|
` ; display-message -p -t ${pane.runtimeId} "${RECOVERY_CURSOR_PROBE_FORMAT}"` +
|
|
2458
2383
|
` ; ${sentinel("cursor-end")}` +
|
|
2459
2384
|
` ; refresh-client -A ${tmuxSingleQuote(`${pane.runtimeId}:continue`)}` +
|
|
@@ -2467,6 +2392,10 @@ export class SessionChannel {
|
|
|
2467
2392
|
` ; ${sentinel("complete")}`;
|
|
2468
2393
|
this.input.flush();
|
|
2469
2394
|
const invoke = (reply: { ok: boolean }): void => {
|
|
2395
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
2396
|
+
cleanupHook();
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2470
2399
|
if (!reply.ok || !participantsExact()) {
|
|
2471
2400
|
fail();
|
|
2472
2401
|
return;
|
|
@@ -2478,6 +2407,8 @@ export class SessionChannel {
|
|
|
2478
2407
|
fail();
|
|
2479
2408
|
return;
|
|
2480
2409
|
}
|
|
2410
|
+
lease.wireNonce = nonce;
|
|
2411
|
+
this.observedPaused.delete(pane.runtimeId);
|
|
2481
2412
|
const armed = this.io.armAtomicPaneSnapshotCollector!(
|
|
2482
2413
|
{
|
|
2483
2414
|
nonce,
|
|
@@ -2485,7 +2416,16 @@ export class SessionChannel {
|
|
|
2485
2416
|
maxCaptureBytes: RECOVERY_CAPTURE_MAX_BYTES,
|
|
2486
2417
|
maxCaptureLines: RECOVERY_CAPTURE_MAX_LINES,
|
|
2487
2418
|
maxCursorBytes: RECOVERY_CURSOR_MAX_BYTES,
|
|
2488
|
-
observerCommandCount: 2,
|
|
2419
|
+
observerCommandCount: safeObserver ? 2 : 0,
|
|
2420
|
+
dualCapture,
|
|
2421
|
+
onDrained: (reason) => {
|
|
2422
|
+
if (lease.wireNonce === nonce) lease.wireNonce = null;
|
|
2423
|
+
if (reason === "channel-exit") {
|
|
2424
|
+
lease.retry = false;
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
this.releaseSnapshotLease(lease);
|
|
2428
|
+
},
|
|
2489
2429
|
onProgress: (progress) => this.noteAtomicCollectorProgress(recovery, nonce, progress),
|
|
2490
2430
|
onSettled: (result: AtomicPaneSnapshotResult) => {
|
|
2491
2431
|
if (recovery.atomicCollectorNonce === nonce) recovery.atomicCollectorNonce = null;
|
|
@@ -2523,13 +2463,37 @@ export class SessionChannel {
|
|
|
2523
2463
|
fail(result.statusObserved);
|
|
2524
2464
|
return;
|
|
2525
2465
|
}
|
|
2466
|
+
if (dualCapture && !result.ansiCaptureLines) {
|
|
2467
|
+
fail(result.statusObserved);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2526
2470
|
if (native) this.nativeBootstrapConfirmed = true;
|
|
2527
2471
|
for (const { sub, epoch } of participants) {
|
|
2528
|
-
if (native) sub.feed.captureNativeReply(epoch, native);
|
|
2529
|
-
else sub.feed.captureReply(epoch, captureLines);
|
|
2472
|
+
if (native && sub.nativeBootstrap) sub.feed.captureNativeReply(epoch, native);
|
|
2473
|
+
else sub.feed.captureReply(epoch, result.ansiCaptureLines ?? captureLines);
|
|
2474
|
+
}
|
|
2475
|
+
const probe = parseCursorProbe(result.cursorLine);
|
|
2476
|
+
const layoutSize = this.layoutCaptureSizeFor(participants[0]!.sub);
|
|
2477
|
+
const pendingLayout =
|
|
2478
|
+
pane.windowRuntimeId !== null && this.pendingLayoutOutput.has(pane.windowRuntimeId);
|
|
2479
|
+
if (
|
|
2480
|
+
!probe ||
|
|
2481
|
+
probe.y >= probe.rows ||
|
|
2482
|
+
(native && (native.cols !== probe.cols || native.rows !== probe.rows))
|
|
2483
|
+
) {
|
|
2484
|
+
fail(result.statusObserved);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (
|
|
2488
|
+
pendingLayout ||
|
|
2489
|
+
(layoutSize && (layoutSize.cols !== probe.cols || layoutSize.rows !== probe.rows))
|
|
2490
|
+
) {
|
|
2491
|
+
this.awaitSnapshotLayout(recovery);
|
|
2492
|
+
fail(result.statusObserved);
|
|
2493
|
+
return;
|
|
2530
2494
|
}
|
|
2531
2495
|
const fallbackSize = this.layoutSizeFor(pane.runtimeId);
|
|
2532
|
-
this.
|
|
2496
|
+
this.observeSnapshotMetadata(pane, result.cursorLine!);
|
|
2533
2497
|
const deliveries = participants.map(({ sub, epoch }) => ({
|
|
2534
2498
|
sub,
|
|
2535
2499
|
epoch,
|
|
@@ -2549,20 +2513,24 @@ export class SessionChannel {
|
|
|
2549
2513
|
const publish = (): boolean => {
|
|
2550
2514
|
if (published) return true;
|
|
2551
2515
|
if (!participantsExact()) return false;
|
|
2552
|
-
published =
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2516
|
+
published = stock.publish(
|
|
2517
|
+
deliveries.map(({ sub, events }) => ({ participant: sub, events })),
|
|
2518
|
+
() => {
|
|
2519
|
+
if (!participantsExact()) throw new Error("snapshot lease retired");
|
|
2520
|
+
return this.snapshotContext(pane);
|
|
2521
|
+
},
|
|
2522
|
+
(participant, event) => (participant as SubRecord).onEvent(event),
|
|
2523
|
+
);
|
|
2524
|
+
return published && participantsExact();
|
|
2556
2525
|
};
|
|
2557
2526
|
if (!deferPublish && !publish()) {
|
|
2558
2527
|
fail(true);
|
|
2559
2528
|
return;
|
|
2560
2529
|
}
|
|
2561
|
-
for (const { sub, epoch } of deliveries) sub.feed.quarantine(epoch);
|
|
2562
2530
|
settled = true;
|
|
2563
2531
|
done({
|
|
2564
2532
|
ok: true,
|
|
2565
|
-
fingerprint:
|
|
2533
|
+
fingerprint: null,
|
|
2566
2534
|
publish,
|
|
2567
2535
|
hold: () => {},
|
|
2568
2536
|
});
|
|
@@ -2571,6 +2539,7 @@ export class SessionChannel {
|
|
|
2571
2539
|
remaining,
|
|
2572
2540
|
);
|
|
2573
2541
|
if (!armed) {
|
|
2542
|
+
lease.wireNonce = null;
|
|
2574
2543
|
fail();
|
|
2575
2544
|
return;
|
|
2576
2545
|
}
|
|
@@ -2595,6 +2564,10 @@ export class SessionChannel {
|
|
|
2595
2564
|
this.io.commandInline(
|
|
2596
2565
|
`set-option -po -t ${pane.runtimeId} ${ownerName} ${nonce}`,
|
|
2597
2566
|
(ownerReply) => {
|
|
2567
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
2568
|
+
cleanupHook();
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2598
2571
|
if (!ownerReply.ok || !participantsExact()) {
|
|
2599
2572
|
fail();
|
|
2600
2573
|
return;
|
|
@@ -2602,6 +2575,10 @@ export class SessionChannel {
|
|
|
2602
2575
|
this.io.commandInline(
|
|
2603
2576
|
`set-option -po -t ${pane.runtimeId} ${expectedName} ${tmuxSingleQuote(body)}`,
|
|
2604
2577
|
(expectedReply) => {
|
|
2578
|
+
if (settled || !this.snapshotLeaseCurrent(lease)) {
|
|
2579
|
+
cleanupHook();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2605
2582
|
if (!expectedReply.ok || !participantsExact()) {
|
|
2606
2583
|
fail();
|
|
2607
2584
|
return;
|
|
@@ -2616,122 +2593,6 @@ export class SessionChannel {
|
|
|
2616
2593
|
);
|
|
2617
2594
|
}
|
|
2618
2595
|
|
|
2619
|
-
private armRecoveryQuiet(recovery: RecoveryRecord, callback: () => void): void {
|
|
2620
|
-
recovery.cancelQuiet?.();
|
|
2621
|
-
recovery.cancelQuiet = this.scheduleRecovery(() => {
|
|
2622
|
-
recovery.cancelQuiet = null;
|
|
2623
|
-
if (this.recoveryPane(recovery)) callback();
|
|
2624
|
-
}, RECOVERY_QUIET_MS);
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
private beginFinalRecovery(recovery: RecoveryRecord): void {
|
|
2628
|
-
const pane = this.recoveryPane(recovery);
|
|
2629
|
-
if (!pane) return;
|
|
2630
|
-
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
2631
|
-
this.failRecovery(recovery, "attempts-exhausted");
|
|
2632
|
-
return;
|
|
2633
|
-
}
|
|
2634
|
-
recovery.attempts += 1;
|
|
2635
|
-
recovery.stage = "final";
|
|
2636
|
-
this.noteRecoveryProgress(recovery);
|
|
2637
|
-
// The final read is only a private candidate. Publishing it here can put
|
|
2638
|
-
// expensive replica projection ahead of the confirmation timers and, more
|
|
2639
|
-
// importantly, exposes a snapshot that has not yet survived the two-read
|
|
2640
|
-
// authority proof.
|
|
2641
|
-
this.reseedRecoverySubscribers(
|
|
2642
|
-
pane,
|
|
2643
|
-
recovery,
|
|
2644
|
-
({ ok, fingerprint }) => {
|
|
2645
|
-
const current = this.recoveryPane(recovery);
|
|
2646
|
-
if (!current) return;
|
|
2647
|
-
if (!ok || fingerprint === null) {
|
|
2648
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2649
|
-
return;
|
|
2650
|
-
}
|
|
2651
|
-
recovery.outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
|
|
2652
|
-
recovery.candidateFingerprint = fingerprint;
|
|
2653
|
-
recovery.confirmationFingerprint = null;
|
|
2654
|
-
recovery.confirmationOrdinal = 0;
|
|
2655
|
-
recovery.stage = "confirm";
|
|
2656
|
-
this.observeRecovery(current, recovery, "final-reseed");
|
|
2657
|
-
this.noteRecoveryProgress(recovery);
|
|
2658
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2659
|
-
},
|
|
2660
|
-
true,
|
|
2661
|
-
);
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
private confirmRecovery(recovery: RecoveryRecord): void {
|
|
2665
|
-
const pane = this.recoveryPane(recovery);
|
|
2666
|
-
if (!pane) return;
|
|
2667
|
-
if ((this.outputOrdinals.get(recovery.runtimeId) ?? 0) !== recovery.outputOrdinal) {
|
|
2668
|
-
this.beginFinalRecovery(recovery);
|
|
2669
|
-
return;
|
|
2670
|
-
}
|
|
2671
|
-
recovery.stage = "final";
|
|
2672
|
-
this.noteRecoveryProgress(recovery);
|
|
2673
|
-
this.reseedRecoverySubscribers(
|
|
2674
|
-
pane,
|
|
2675
|
-
recovery,
|
|
2676
|
-
({ ok, fingerprint, publish }) => {
|
|
2677
|
-
const current = this.recoveryPane(recovery);
|
|
2678
|
-
if (!current) return;
|
|
2679
|
-
if (!ok || fingerprint === null) {
|
|
2680
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2681
|
-
return;
|
|
2682
|
-
}
|
|
2683
|
-
const outputOrdinal = this.outputOrdinals.get(recovery.runtimeId) ?? 0;
|
|
2684
|
-
const ordinalExact = outputOrdinal === recovery.outputOrdinal;
|
|
2685
|
-
const candidateExact = ordinalExact && fingerprint === recovery.candidateFingerprint;
|
|
2686
|
-
const consecutiveExact =
|
|
2687
|
-
candidateExact &&
|
|
2688
|
-
recovery.confirmationFingerprint !== null &&
|
|
2689
|
-
fingerprint === recovery.confirmationFingerprint;
|
|
2690
|
-
recovery.confirmationOrdinal += 1;
|
|
2691
|
-
this.observeRecovery(current, recovery, "confirmation-reseed", null, consecutiveExact);
|
|
2692
|
-
this.noteRecoveryProgress(recovery);
|
|
2693
|
-
if (!ordinalExact) {
|
|
2694
|
-
recovery.stage = "confirm";
|
|
2695
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2696
|
-
return;
|
|
2697
|
-
}
|
|
2698
|
-
if (!candidateExact) {
|
|
2699
|
-
if (recovery.attempts >= RECOVERY_MAX_ATTEMPTS) {
|
|
2700
|
-
this.failRecovery(recovery, "attempts-exhausted");
|
|
2701
|
-
return;
|
|
2702
|
-
}
|
|
2703
|
-
// A changed confirmation replaces the private candidate without
|
|
2704
|
-
// becoming observable. Two subsequent reads must confirm this truth.
|
|
2705
|
-
recovery.attempts += 1;
|
|
2706
|
-
recovery.candidateFingerprint = fingerprint;
|
|
2707
|
-
recovery.confirmationFingerprint = null;
|
|
2708
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2709
|
-
recovery.stage = "confirm";
|
|
2710
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2711
|
-
return;
|
|
2712
|
-
}
|
|
2713
|
-
if (!consecutiveExact) {
|
|
2714
|
-
recovery.confirmationFingerprint = fingerprint;
|
|
2715
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2716
|
-
recovery.stage = "confirm";
|
|
2717
|
-
this.armRecoveryQuiet(recovery, () => this.confirmRecovery(recovery));
|
|
2718
|
-
return;
|
|
2719
|
-
}
|
|
2720
|
-
// This publisher belongs to the current second matching read, not the
|
|
2721
|
-
// older candidate. Publish exactly once while every participant is
|
|
2722
|
-
// still fenced, then synchronously retire recovery before downstream
|
|
2723
|
-
// projection work can run.
|
|
2724
|
-
if (!publish()) {
|
|
2725
|
-
recovery.stage = "confirm";
|
|
2726
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2727
|
-
return;
|
|
2728
|
-
}
|
|
2729
|
-
this.convergeRecovery(current, recovery);
|
|
2730
|
-
},
|
|
2731
|
-
true,
|
|
2732
|
-
);
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
2596
|
private convergeRecovery(pane: PaneRecord, recovery: RecoveryRecord): void {
|
|
2736
2597
|
recovery.cancelCommandDeadline?.();
|
|
2737
2598
|
recovery.cancelCommandDeadline = null;
|
|
@@ -2741,37 +2602,26 @@ export class SessionChannel {
|
|
|
2741
2602
|
recovery.cancelAbsoluteDeadline = null;
|
|
2742
2603
|
this.recoveries.delete(recovery.runtimeId);
|
|
2743
2604
|
recovery.retired = true;
|
|
2744
|
-
this.retireContinueNotificationOwner(recovery);
|
|
2745
2605
|
this.ledger.noteContinued(recovery.runtimeId);
|
|
2746
2606
|
if (recovery.reason === "requested") this.ledger.clearRequest(recovery.runtimeId);
|
|
2747
2607
|
for (const sub of pane.subs) {
|
|
2748
2608
|
if (!sub.frozen && !sub.closed) {
|
|
2749
2609
|
sub.feed.releaseQuarantine();
|
|
2750
|
-
|
|
2610
|
+
try {
|
|
2611
|
+
sub.onEvent({ type: "flow", state: "resumed", reason: recovery.reason });
|
|
2612
|
+
} catch {
|
|
2613
|
+
// A consumer cannot strand shared snapshot admission for other panes.
|
|
2614
|
+
}
|
|
2751
2615
|
}
|
|
2752
2616
|
}
|
|
2753
|
-
this.observeRecovery(pane, recovery, "converged", null
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
private noteRecoveryOutput(pane: PaneRecord, outputOrdinal: number): void {
|
|
2757
|
-
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2758
|
-
if (!recovery || recovery.paneIncarnation !== pane.incarnation) return;
|
|
2759
|
-
recovery.outputOrdinal = outputOrdinal;
|
|
2760
|
-
if (recovery.continueReply) this.noteRecoveryProgress(recovery);
|
|
2761
|
-
if (recovery.stage === "quiet")
|
|
2762
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2763
|
-
else if (recovery.stage === "confirm")
|
|
2764
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2617
|
+
this.observeRecovery(pane, recovery, "converged", null);
|
|
2618
|
+
if (recovery.lease) this.retireSnapshotLease(recovery.lease);
|
|
2765
2619
|
}
|
|
2766
2620
|
|
|
2767
2621
|
private restartRecoveryAfterOutputOverflow(pane: PaneRecord): void {
|
|
2768
2622
|
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2769
|
-
if (recovery
|
|
2770
|
-
|
|
2771
|
-
this.armRecoveryQuiet(recovery, () => this.beginFinalRecovery(recovery));
|
|
2772
|
-
return;
|
|
2773
|
-
}
|
|
2774
|
-
this.beginLocalOverflowRecovery(pane);
|
|
2623
|
+
if (recovery) this.retrySnapshot(recovery);
|
|
2624
|
+
else this.beginLocalOverflowRecovery(pane);
|
|
2775
2625
|
}
|
|
2776
2626
|
|
|
2777
2627
|
private failRecovery(
|
|
@@ -2780,8 +2630,6 @@ export class SessionChannel {
|
|
|
2780
2630
|
): void {
|
|
2781
2631
|
const pane = this.recoveryPane(recovery);
|
|
2782
2632
|
if (!pane) return;
|
|
2783
|
-
recovery.cancelQuiet?.();
|
|
2784
|
-
recovery.cancelQuiet = null;
|
|
2785
2633
|
recovery.cancelCommandDeadline?.();
|
|
2786
2634
|
recovery.cancelCommandDeadline = null;
|
|
2787
2635
|
recovery.cancelNoProgressDeadline?.();
|
|
@@ -2789,12 +2637,13 @@ export class SessionChannel {
|
|
|
2789
2637
|
recovery.cancelAbsoluteDeadline?.();
|
|
2790
2638
|
recovery.cancelAbsoluteDeadline = null;
|
|
2791
2639
|
recovery.retired = true;
|
|
2640
|
+
this.snapshotQueue.delete(recovery.runtimeId);
|
|
2641
|
+
this.recoveries.delete(recovery.runtimeId);
|
|
2642
|
+
for (const sub of pane.subs) sub.feed.abortCurrent();
|
|
2643
|
+
if (recovery.lease) this.retireSnapshotLease(recovery.lease);
|
|
2792
2644
|
const collectorNonce = recovery.atomicCollectorNonce;
|
|
2793
2645
|
recovery.atomicCollectorNonce = null;
|
|
2794
2646
|
if (collectorNonce) this.io.retireAtomicPaneSnapshotCollector?.(collectorNonce, "retired");
|
|
2795
|
-
this.recoveries.delete(recovery.runtimeId);
|
|
2796
|
-
this.retireContinueNotificationOwner(recovery);
|
|
2797
|
-
for (const sub of pane.subs) sub.feed.abortCurrent();
|
|
2798
2647
|
this.observeRecovery(pane, recovery, "nonconverged", failureReason);
|
|
2799
2648
|
for (const sub of pane.subs) {
|
|
2800
2649
|
sub.cancelCapture?.();
|
|
@@ -2807,49 +2656,6 @@ export class SessionChannel {
|
|
|
2807
2656
|
}
|
|
2808
2657
|
}
|
|
2809
2658
|
|
|
2810
|
-
private removeContinueNotificationOwner(recovery: RecoveryRecord): void {
|
|
2811
|
-
const queue = this.continueNotificationQueues.get(recovery.runtimeId);
|
|
2812
|
-
if (!queue) return;
|
|
2813
|
-
const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
|
|
2814
|
-
if (index < 0) return;
|
|
2815
|
-
queue.splice(index, 1);
|
|
2816
|
-
this.compactContinueNotificationQueue(recovery.runtimeId, queue);
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
|
-
private retireContinueNotificationOwner(recovery: RecoveryRecord): void {
|
|
2820
|
-
if (!recovery.continueReply) return;
|
|
2821
|
-
const queue = this.continueNotificationQueues.get(recovery.runtimeId);
|
|
2822
|
-
if (!queue) return;
|
|
2823
|
-
const index = queue.findIndex((entry) => entry.kind === "owner" && entry.recovery === recovery);
|
|
2824
|
-
if (index < 0) return;
|
|
2825
|
-
queue.splice(index, 1, { kind: "debt", count: 1, saturated: false });
|
|
2826
|
-
this.compactContinueNotificationQueue(recovery.runtimeId, queue);
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
private compactContinueNotificationQueue(
|
|
2830
|
-
runtime: string,
|
|
2831
|
-
queue: ContinueNotificationEntry[],
|
|
2832
|
-
): void {
|
|
2833
|
-
for (let index = 1; index < queue.length; ) {
|
|
2834
|
-
const previous = queue[index - 1];
|
|
2835
|
-
const current = queue[index];
|
|
2836
|
-
if (previous?.kind !== "debt" || current?.kind !== "debt") {
|
|
2837
|
-
index += 1;
|
|
2838
|
-
continue;
|
|
2839
|
-
}
|
|
2840
|
-
const total = previous.count + current.count;
|
|
2841
|
-
previous.count = Math.min(total, MAX_CONTINUE_NOTIFICATION_DEBT);
|
|
2842
|
-
previous.saturated =
|
|
2843
|
-
previous.saturated || current.saturated || total > MAX_CONTINUE_NOTIFICATION_DEBT;
|
|
2844
|
-
queue.splice(index, 1);
|
|
2845
|
-
}
|
|
2846
|
-
if (queue.length === 0) this.continueNotificationQueues.delete(runtime);
|
|
2847
|
-
else this.continueNotificationQueues.set(runtime, queue);
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
/** Continue + reseed EVERY backpressure-paused pane that still has an
|
|
2851
|
-
* unfrozen subscriber. %pause is sticky and hits quiet panes after any
|
|
2852
|
-
* stall — recovering only the noisy pane leaves siblings dark. */
|
|
2853
2659
|
private recoverSticky(): void {
|
|
2854
2660
|
for (const runtime of this.ledger.stickyRecoverySet()) {
|
|
2855
2661
|
if (this.recoveries.has(runtime)) continue;
|
|
@@ -2866,6 +2672,12 @@ export class SessionChannel {
|
|
|
2866
2672
|
sub.cancelCapture?.();
|
|
2867
2673
|
const pane = sub.pane;
|
|
2868
2674
|
pane.subs.delete(sub);
|
|
2675
|
+
const activeRecovery = this.recoveries.get(pane.runtimeId);
|
|
2676
|
+
if (
|
|
2677
|
+
activeRecovery &&
|
|
2678
|
+
[...pane.subs].some((candidate) => !candidate.closed && !candidate.frozen)
|
|
2679
|
+
)
|
|
2680
|
+
this.retrySnapshot(activeRecovery);
|
|
2869
2681
|
if ([...pane.subs].every((candidate) => candidate.closed || candidate.frozen))
|
|
2870
2682
|
this.cancelRecovery(pane.runtimeId);
|
|
2871
2683
|
// Ticket return on departure: a pane parked by a now-gone subscriber must
|
|
@@ -2878,8 +2690,11 @@ export class SessionChannel {
|
|
|
2878
2690
|
|
|
2879
2691
|
// ── Notifications (channel order is the invariant) ──────────────────────
|
|
2880
2692
|
|
|
2881
|
-
private
|
|
2882
|
-
const
|
|
2693
|
+
private observeSnapshotMetadata(pane: PaneRecord, cursorLine: string): void {
|
|
2694
|
+
const fields = cursorLine.trim().split(/\s+/);
|
|
2695
|
+
const historySize = Number(fields[14]);
|
|
2696
|
+
if (Number.isSafeInteger(historySize) && historySize >= 0) pane.historySize = historySize;
|
|
2697
|
+
const value = fields[22];
|
|
2883
2698
|
pane.scrollOnClear = value === "0" || value === "1" ? value === "1" : undefined;
|
|
2884
2699
|
}
|
|
2885
2700
|
|
|
@@ -2932,13 +2747,21 @@ export class SessionChannel {
|
|
|
2932
2747
|
return;
|
|
2933
2748
|
}
|
|
2934
2749
|
}
|
|
2935
|
-
|
|
2936
|
-
name === "subscription-changed"
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2750
|
+
const windowOptionHint =
|
|
2751
|
+
name === "subscription-changed"
|
|
2752
|
+
? (/^tmux-ide-pane-borders\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:top|bottom|off)\s*$/u.exec(
|
|
2753
|
+
rest,
|
|
2754
|
+
) ??
|
|
2755
|
+
/^tmux-ide-copy-keys\s+\$[0-9]+\s+(@[0-9]+)\s+[0-9]+\s+-\s*:\s*(?:emacs|vi)\s*$/u.exec(
|
|
2756
|
+
rest,
|
|
2757
|
+
))
|
|
2758
|
+
: null;
|
|
2759
|
+
if (windowOptionHint) {
|
|
2760
|
+
// Initial option samples from sibling windows are not evidence that this
|
|
2761
|
+
// pane's capture context changed. Unknown membership stays conservative.
|
|
2762
|
+
for (const pane of this.panesByRuntime.values())
|
|
2763
|
+
if (!pane.windowRuntimeId || pane.windowRuntimeId === windowOptionHint[1])
|
|
2764
|
+
pane.snapshotLayoutGeneration += 1;
|
|
2942
2765
|
this.windowAuthorityOrdinal += 1;
|
|
2943
2766
|
this.windowIdentityOrdinal += 1;
|
|
2944
2767
|
this.scheduleSync();
|
|
@@ -2951,6 +2774,8 @@ export class SessionChannel {
|
|
|
2951
2774
|
STRUCTURAL_NOTIFICATIONS.has(name)
|
|
2952
2775
|
) {
|
|
2953
2776
|
this.windowAuthorityOrdinal += 1;
|
|
2777
|
+
// Structural and layout notifications remain conservative across all panes.
|
|
2778
|
+
for (const pane of this.panesByRuntime.values()) pane.snapshotLayoutGeneration += 1;
|
|
2954
2779
|
if (name !== "layout-change") this.windowIdentityOrdinal += 1;
|
|
2955
2780
|
}
|
|
2956
2781
|
// Layout changes remain a second honest wake-up: a native resize can arrive
|
|
@@ -2965,8 +2790,18 @@ export class SessionChannel {
|
|
|
2965
2790
|
if (name === "pause") {
|
|
2966
2791
|
const runtime = rest.trim().split(/\s+/)[0] ?? "";
|
|
2967
2792
|
if (!runtime.startsWith("%")) return;
|
|
2968
|
-
|
|
2793
|
+
this.observedPaused.add(runtime);
|
|
2794
|
+
const stockRecovery = this.recoveries.get(runtime);
|
|
2795
|
+
if (stockRecovery?.stock && stockRecovery.lease) {
|
|
2796
|
+
this.ledger.notePause(runtime);
|
|
2797
|
+
if (stockRecovery.stage !== "stock-pause") this.retrySnapshot(stockRecovery);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
const nativeRecovery = this.recoveries.get(runtime);
|
|
2801
|
+
if (nativeRecovery) {
|
|
2969
2802
|
this.ledger.notePause(runtime);
|
|
2803
|
+
if (nativeRecovery.stage !== "native-pause" && nativeRecovery.stage !== "queued")
|
|
2804
|
+
this.retrySnapshot(nativeRecovery);
|
|
2970
2805
|
return;
|
|
2971
2806
|
}
|
|
2972
2807
|
this.cancelRecovery(runtime);
|
|
@@ -2984,26 +2819,7 @@ export class SessionChannel {
|
|
|
2984
2819
|
}
|
|
2985
2820
|
if (name === "continue") {
|
|
2986
2821
|
const runtime = rest.trim().split(/\s+/)[0] ?? "";
|
|
2987
|
-
|
|
2988
|
-
const queue = this.continueNotificationQueues.get(runtime);
|
|
2989
|
-
const entry = queue?.[0] ?? null;
|
|
2990
|
-
if (entry?.kind === "debt") {
|
|
2991
|
-
if (!entry.saturated) {
|
|
2992
|
-
entry.count -= 1;
|
|
2993
|
-
if (entry.count === 0) queue!.shift();
|
|
2994
|
-
}
|
|
2995
|
-
this.compactContinueNotificationQueue(runtime, queue!);
|
|
2996
|
-
} else if (entry?.kind === "owner") {
|
|
2997
|
-
queue!.shift();
|
|
2998
|
-
this.compactContinueNotificationQueue(runtime, queue!);
|
|
2999
|
-
const owner = entry.recovery;
|
|
3000
|
-
const pane = this.recoveryPane(owner);
|
|
3001
|
-
owner.continueNotify = true;
|
|
3002
|
-
if (pane) {
|
|
3003
|
-
this.observeRecovery(pane, owner, "continue-notify");
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
}
|
|
2822
|
+
this.observedPaused.delete(runtime);
|
|
3007
2823
|
return;
|
|
3008
2824
|
}
|
|
3009
2825
|
if (name === "layout-change") {
|
|
@@ -3019,6 +2835,14 @@ export class SessionChannel {
|
|
|
3019
2835
|
return;
|
|
3020
2836
|
}
|
|
3021
2837
|
this.layoutNotificationOrdinals.set(change.windowId, this.windowAuthorityOrdinal);
|
|
2838
|
+
for (const pane of this.panesByRuntime.values()) {
|
|
2839
|
+
if (pane.windowRuntimeId !== change.windowId) continue;
|
|
2840
|
+
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2841
|
+
if (recovery?.lease && !recovery.lease.retired) {
|
|
2842
|
+
this.awaitSnapshotLayout(recovery);
|
|
2843
|
+
this.retrySnapshot(recovery);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
3022
2846
|
const pendingLayout = {
|
|
3023
2847
|
...parsed,
|
|
3024
2848
|
zoomed: change.zoomed,
|
|
@@ -3341,6 +3165,8 @@ export class SessionChannel {
|
|
|
3341
3165
|
if (pane.windowRuntimeId && this.pendingLayoutOutput.has(pane.windowRuntimeId)) continue;
|
|
3342
3166
|
for (const sub of pane.subs) sub.resumeLayoutCapture?.(syncOrdinal);
|
|
3343
3167
|
}
|
|
3168
|
+
this.completedSyncOrdinal = Math.max(this.completedSyncOrdinal, syncOrdinal);
|
|
3169
|
+
this.drainSnapshots();
|
|
3344
3170
|
this.discovery.discover(listed);
|
|
3345
3171
|
}
|
|
3346
3172
|
|
|
@@ -3372,11 +3198,11 @@ export class SessionChannel {
|
|
|
3372
3198
|
const nextWindowRuntimeId = this.truthWindow.get(runtime) ?? pane.windowRuntimeId;
|
|
3373
3199
|
if (nextWindowRuntimeId !== pane.windowRuntimeId && nextWindowRuntimeId !== null)
|
|
3374
3200
|
movedWindowRuntimeIds.add(nextWindowRuntimeId);
|
|
3201
|
+
if (pane.windowRuntimeId !== nextWindowRuntimeId) pane.snapshotLayoutGeneration += 1;
|
|
3375
3202
|
pane.windowRuntimeId = nextWindowRuntimeId;
|
|
3376
3203
|
continue;
|
|
3377
3204
|
}
|
|
3378
3205
|
this.cancelRecovery(runtime);
|
|
3379
|
-
this.continueNotificationQueues.delete(runtime);
|
|
3380
3206
|
this.panesByRuntime.delete(runtime);
|
|
3381
3207
|
this.outputOrdinals.delete(runtime);
|
|
3382
3208
|
this.panesBySemantic.delete(pane.semanticId);
|
|
@@ -3386,7 +3212,11 @@ export class SessionChannel {
|
|
|
3386
3212
|
if (!sub.closed) {
|
|
3387
3213
|
sub.closed = true;
|
|
3388
3214
|
sub.cancelCapture?.();
|
|
3389
|
-
|
|
3215
|
+
try {
|
|
3216
|
+
sub.onEvent({ type: "closed" });
|
|
3217
|
+
} catch {
|
|
3218
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
3219
|
+
}
|
|
3390
3220
|
}
|
|
3391
3221
|
}
|
|
3392
3222
|
pane.subs.clear();
|
|
@@ -3997,7 +3827,6 @@ export class SessionChannel {
|
|
|
3997
3827
|
// address's bytes are a different pane's now.
|
|
3998
3828
|
const retiredRuntime = existingBySemantic.runtimeId;
|
|
3999
3829
|
this.cancelRecovery(retiredRuntime);
|
|
4000
|
-
this.continueNotificationQueues.delete(retiredRuntime);
|
|
4001
3830
|
this.panesByRuntime.delete(retiredRuntime);
|
|
4002
3831
|
this.outputOrdinals.delete(retiredRuntime);
|
|
4003
3832
|
this.ledger.forget(retiredRuntime);
|
|
@@ -4005,6 +3834,8 @@ export class SessionChannel {
|
|
|
4005
3834
|
existingBySemantic.incarnation = ++this.paneIncarnation;
|
|
4006
3835
|
existingBySemantic.descriptor = descriptor;
|
|
4007
3836
|
existingBySemantic.active = verified.active;
|
|
3837
|
+
if (existingBySemantic.windowRuntimeId !== windowRuntimeId)
|
|
3838
|
+
existingBySemantic.snapshotLayoutGeneration += 1;
|
|
4008
3839
|
existingBySemantic.windowRuntimeId = windowRuntimeId;
|
|
4009
3840
|
this.panesByRuntime.set(verified.runtimePaneId, existingBySemantic);
|
|
4010
3841
|
for (const sub of existingBySemantic.subs) {
|
|
@@ -4015,6 +3846,8 @@ export class SessionChannel {
|
|
|
4015
3846
|
if (existingByRuntime && existingByRuntime.semanticId === verified.semanticPaneId) {
|
|
4016
3847
|
existingByRuntime.descriptor = descriptor;
|
|
4017
3848
|
existingByRuntime.active = verified.active;
|
|
3849
|
+
if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
|
|
3850
|
+
existingByRuntime.snapshotLayoutGeneration += 1;
|
|
4018
3851
|
existingByRuntime.windowRuntimeId = windowRuntimeId;
|
|
4019
3852
|
continue;
|
|
4020
3853
|
}
|
|
@@ -4023,7 +3856,6 @@ export class SessionChannel {
|
|
|
4023
3856
|
// resolution). The old semantic id is gone.
|
|
4024
3857
|
this.panesBySemantic.delete(existingByRuntime.semanticId);
|
|
4025
3858
|
this.cancelRecovery(existingByRuntime.runtimeId);
|
|
4026
|
-
this.continueNotificationQueues.delete(existingByRuntime.runtimeId);
|
|
4027
3859
|
this.ledger.forget(existingByRuntime.runtimeId);
|
|
4028
3860
|
this.outputOrdinals.delete(existingByRuntime.runtimeId);
|
|
4029
3861
|
for (const sub of existingByRuntime.subs) {
|
|
@@ -4031,13 +3863,19 @@ export class SessionChannel {
|
|
|
4031
3863
|
sub.closed = true;
|
|
4032
3864
|
sub.cancelCapture?.();
|
|
4033
3865
|
sub.feed.abortCurrent();
|
|
4034
|
-
|
|
3866
|
+
try {
|
|
3867
|
+
sub.onEvent({ type: "closed" });
|
|
3868
|
+
} catch {
|
|
3869
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
3870
|
+
}
|
|
4035
3871
|
}
|
|
4036
3872
|
existingByRuntime.subs.clear();
|
|
4037
3873
|
existingByRuntime.incarnation = ++this.paneIncarnation;
|
|
4038
3874
|
existingByRuntime.semanticId = verified.semanticPaneId;
|
|
4039
3875
|
existingByRuntime.descriptor = descriptor;
|
|
4040
3876
|
existingByRuntime.active = verified.active;
|
|
3877
|
+
if (existingByRuntime.windowRuntimeId !== windowRuntimeId)
|
|
3878
|
+
existingByRuntime.snapshotLayoutGeneration += 1;
|
|
4041
3879
|
existingByRuntime.windowRuntimeId = windowRuntimeId;
|
|
4042
3880
|
this.panesBySemantic.set(verified.semanticPaneId, existingByRuntime);
|
|
4043
3881
|
continue;
|
|
@@ -4050,6 +3888,7 @@ export class SessionChannel {
|
|
|
4050
3888
|
windowRuntimeId,
|
|
4051
3889
|
subs: new Set(),
|
|
4052
3890
|
incarnation: ++this.paneIncarnation,
|
|
3891
|
+
snapshotLayoutGeneration: 0,
|
|
4053
3892
|
};
|
|
4054
3893
|
this.panesByRuntime.set(record.runtimeId, record);
|
|
4055
3894
|
this.panesBySemantic.set(record.semanticId, record);
|
|
@@ -4160,18 +3999,25 @@ export class SessionChannel {
|
|
|
4160
3999
|
|
|
4161
4000
|
private onChannelExit(): void {
|
|
4162
4001
|
if (this.disposed) return;
|
|
4002
|
+
this.snapshotActive?.cancelFence?.();
|
|
4003
|
+
this.snapshotActive = null;
|
|
4004
|
+
this.snapshotQueue.clear();
|
|
4005
|
+
for (const recovery of this.recoveries.values()) recovery.lease = undefined;
|
|
4163
4006
|
this.opts.ownedViewer?.dispose();
|
|
4164
4007
|
this.windowLinkAuthority?.dispose();
|
|
4165
4008
|
this.nativeGrid.dispose();
|
|
4166
4009
|
this.settleFirstJoin();
|
|
4167
4010
|
for (const runtime of [...this.recoveries.keys()]) this.cancelRecovery(runtime);
|
|
4168
|
-
this.continueNotificationQueues.clear();
|
|
4169
4011
|
for (const pane of this.panesByRuntime.values()) {
|
|
4170
4012
|
for (const sub of pane.subs) {
|
|
4171
4013
|
if (!sub.closed) {
|
|
4172
4014
|
sub.closed = true;
|
|
4173
4015
|
sub.cancelCapture?.();
|
|
4174
|
-
|
|
4016
|
+
try {
|
|
4017
|
+
sub.onEvent({ type: "closed" });
|
|
4018
|
+
} catch {
|
|
4019
|
+
// Close every sibling and the transport even if a consumer throws.
|
|
4020
|
+
}
|
|
4175
4021
|
}
|
|
4176
4022
|
}
|
|
4177
4023
|
pane.subs.clear();
|