nterminal 1.3.25 → 1.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/client/assets/MarkdownPreview-Dz-gstWx.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-BkgxZPQT.js → TranscriptMarkdownBody-wSYk8MdN.js} +1 -1
- package/dist/client/assets/{index-CT36T3oC.js → index-D7G-v5g3.js} +1 -1
- package/dist/client/assets/{index-BauayuRF.js → index-WcP7tpZM.js} +32 -32
- package/dist/client/index.html +1 -1
- package/dist/server/agent/agentRoutes.js +6 -1
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/index.js +14 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/activityRoutes.js +3 -2
- package/dist/server/routes/activityRoutes.js.map +1 -1
- package/dist/server/routes/agentManagementRoutes.js +19 -1
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/server/routes/terminalRoutes.d.ts +4 -1
- package/dist/server/routes/terminalRoutes.js +15 -1
- package/dist/server/routes/terminalRoutes.js.map +1 -1
- package/dist/server/routes/terminalWebSocket.js +4 -0
- package/dist/server/routes/terminalWebSocket.js.map +1 -1
- package/dist/server/storage/terminalLayoutCleanup.d.ts +4 -0
- package/dist/server/storage/terminalLayoutCleanup.js +103 -0
- package/dist/server/storage/terminalLayoutCleanup.js.map +1 -0
- package/dist/server/terminal/TerminalManager.d.ts +23 -1
- package/dist/server/terminal/TerminalManager.js +338 -77
- package/dist/server/terminal/TerminalManager.js.map +1 -1
- package/dist/server/terminal/terminalSessionStore.d.ts +27 -0
- package/dist/server/terminal/terminalSessionStore.js +124 -0
- package/dist/server/terminal/terminalSessionStore.js.map +1 -0
- package/dist/shared/layoutState.d.ts +2 -0
- package/dist/shared/layoutState.js +3 -0
- package/dist/shared/layoutState.js.map +1 -1
- package/dist/shared/protocol.d.ts +2 -0
- package/dist/shared/protocol.js.map +1 -1
- package/docs/configuration.md +53 -0
- package/package.json +1 -1
- package/dist/client/assets/MarkdownPreview-D7uk3GJX.js +0 -1
|
@@ -45,6 +45,7 @@ const BUSY_OUTPUT_WINDOW_MS = 2_500;
|
|
|
45
45
|
// which is fast enough to feel responsive without spamming polls.
|
|
46
46
|
const STATUS_POLL_INTERVAL_MS = 2_000;
|
|
47
47
|
const RESTORE_RETRY_INTERVAL_MS = 2_000;
|
|
48
|
+
const RESTORE_RETRY_MAX_INTERVAL_MS = 30_000;
|
|
48
49
|
const TRANSCRIPT_POLL_CONCURRENCY = 2;
|
|
49
50
|
const TRANSCRIPT_SOURCE_MISS_GRACE_MS = 10_000;
|
|
50
51
|
const TRANSCRIPT_SLOW_WARNING_MS = 3_000;
|
|
@@ -54,6 +55,8 @@ const HISTORY_PAGE_LINES = 1000;
|
|
|
54
55
|
const HISTORY_PAGE_MAX_LINES = 5000;
|
|
55
56
|
const MAX_PENDING_CONTROL_OPERATIONS = 256;
|
|
56
57
|
const MAX_PENDING_CONTROL_BYTES = 16 * 1024 * 1024;
|
|
58
|
+
const RECOVERY_CHECKPOINT_INTERVAL_MS = 10_000;
|
|
59
|
+
const RECOVERY_SAVE_DELAY_MS = 1_000;
|
|
57
60
|
export class TerminalManager {
|
|
58
61
|
config;
|
|
59
62
|
ptyAdapter;
|
|
@@ -92,6 +95,7 @@ export class TerminalManager {
|
|
|
92
95
|
completionStates = new Map();
|
|
93
96
|
statusPollTimer = null;
|
|
94
97
|
restoreRetryTimer = null;
|
|
98
|
+
restoreRetryDelayMs = RESTORE_RETRY_INTERVAL_MS;
|
|
95
99
|
restoreInflight = null;
|
|
96
100
|
statusPollInflight = false;
|
|
97
101
|
activeTranscriptWorkers = 0;
|
|
@@ -104,9 +108,18 @@ export class TerminalManager {
|
|
|
104
108
|
terminalMutationGeneration = 0;
|
|
105
109
|
shutdownRequested = false;
|
|
106
110
|
disposed = false;
|
|
111
|
+
sessionStore;
|
|
112
|
+
recoveryReady = false;
|
|
113
|
+
recoveryTimer = null;
|
|
114
|
+
recoveryWriteTimer = null;
|
|
115
|
+
recoveryCheckpoint = null;
|
|
116
|
+
pendingSessionRestores = new Map();
|
|
117
|
+
creatingSessionIds = new Set();
|
|
118
|
+
pendingDeletionCancellations = new Map();
|
|
107
119
|
constructor(config, ptyAdapter, options = {}) {
|
|
108
120
|
this.config = config;
|
|
109
121
|
this.ptyAdapter = ptyAdapter;
|
|
122
|
+
this.sessionStore = options.sessionStore;
|
|
110
123
|
this.transcriptStore = new TranscriptStore(path.join(path.dirname(config.statePath), 'transcript-cache'));
|
|
111
124
|
this.resolveCwd = options.resolveCwd ?? resolveProcessCwd;
|
|
112
125
|
this.now = options.now ?? (() => new Date());
|
|
@@ -125,6 +138,14 @@ export class TerminalManager {
|
|
|
125
138
|
if (typeof this.ptyAdapter.pollSessions === 'function') {
|
|
126
139
|
this.startStatusPoller();
|
|
127
140
|
}
|
|
141
|
+
if (this.sessionStore) {
|
|
142
|
+
this.recoveryTimer = setInterval(() => {
|
|
143
|
+
if (this.recoveryReady && !this.shutdownRequested && !this.disposed) {
|
|
144
|
+
void this.checkpointRecovery().catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }));
|
|
145
|
+
}
|
|
146
|
+
}, RECOVERY_CHECKPOINT_INTERVAL_MS);
|
|
147
|
+
this.recoveryTimer.unref?.();
|
|
148
|
+
}
|
|
128
149
|
}
|
|
129
150
|
startStatusPoller() {
|
|
130
151
|
if (this.statusPollTimer)
|
|
@@ -393,6 +414,11 @@ export class TerminalManager {
|
|
|
393
414
|
this.statusPollTimer = null;
|
|
394
415
|
}
|
|
395
416
|
this.clearRestoreRetryTimer();
|
|
417
|
+
if (this.recoveryTimer)
|
|
418
|
+
clearInterval(this.recoveryTimer);
|
|
419
|
+
if (this.recoveryWriteTimer)
|
|
420
|
+
clearTimeout(this.recoveryWriteTimer);
|
|
421
|
+
this.recoveryTimer = this.recoveryWriteTimer = null;
|
|
396
422
|
this.transcriptQueue.length = 0;
|
|
397
423
|
for (const record of this.terminals.values()) {
|
|
398
424
|
record.transcriptRerun = false;
|
|
@@ -402,6 +428,7 @@ export class TerminalManager {
|
|
|
402
428
|
}
|
|
403
429
|
}
|
|
404
430
|
async createTerminal(options = {}) {
|
|
431
|
+
this.assertRecoveryReady();
|
|
405
432
|
if (this.shutdownRequested || this.terminalResetBlockers > 0) {
|
|
406
433
|
throw new Error('terminal manager is not accepting new terminals');
|
|
407
434
|
}
|
|
@@ -421,33 +448,57 @@ export class TerminalManager {
|
|
|
421
448
|
const id = randomUUID();
|
|
422
449
|
const timestamp = this.timestamp();
|
|
423
450
|
const initialTitle = this.createTitle(cwd);
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
+
const summary = {
|
|
452
|
+
id, title: initialTitle, cwd, pid: null, cols, rows,
|
|
453
|
+
createdAt: timestamp, updatedAt: timestamp,
|
|
454
|
+
status: 'running', exitCode: null,
|
|
455
|
+
...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
|
|
456
|
+
};
|
|
457
|
+
this.creatingSessionIds.add(id);
|
|
458
|
+
try {
|
|
459
|
+
// An incomplete creation may be adopted if still alive, but must never
|
|
460
|
+
// recreate a missing shell after a failed spawn or an interrupted request.
|
|
461
|
+
await this.sessionStore?.prepareCreation(summary);
|
|
462
|
+
let pty;
|
|
463
|
+
try {
|
|
464
|
+
pty = await this.ptyAdapter.spawn({
|
|
465
|
+
sessionId: id, shell: this.config.shell, cwd, cols, rows,
|
|
466
|
+
env: terminalEnv(), title: initialTitle
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
await this.forgetRecoverySession(id);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
const record = this.createRecord(summary);
|
|
474
|
+
this.wireUpPty(record, pty);
|
|
475
|
+
this.terminals.set(id, record);
|
|
476
|
+
try {
|
|
477
|
+
await this.sessionStore?.save([record.summary]);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
// Keep the real shell discoverable if its final checkpoint fails.
|
|
481
|
+
// Background persistence retries it; never replace or kill that shell.
|
|
482
|
+
this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
|
|
483
|
+
this.scheduleRestoreRetry();
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
return cloneSummary(record.summary);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
// prepareCreation may have renamed its file before a later fsync failed.
|
|
490
|
+
if (this.sessionStore)
|
|
491
|
+
this.scheduleRestoreRetry();
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
this.creatingSessionIds.delete(id);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
createRecord(summary) {
|
|
499
|
+
return {
|
|
500
|
+
summary,
|
|
501
|
+
pty: null,
|
|
451
502
|
output: new OutputBuffer(),
|
|
452
503
|
subscribers: new Set(),
|
|
453
504
|
dataDisposable: { dispose: () => undefined },
|
|
@@ -469,9 +520,6 @@ export class TerminalManager {
|
|
|
469
520
|
closePromise: null,
|
|
470
521
|
closing: false
|
|
471
522
|
};
|
|
472
|
-
this.wireUpPty(record, pty);
|
|
473
|
-
this.terminals.set(id, record);
|
|
474
|
-
return cloneSummary(record.summary);
|
|
475
523
|
}
|
|
476
524
|
// Repopulate the in-memory record map with any persistent sessions the
|
|
477
525
|
// adapter is already holding for us — used at process start so that after
|
|
@@ -491,7 +539,7 @@ export class TerminalManager {
|
|
|
491
539
|
return;
|
|
492
540
|
}
|
|
493
541
|
if (this.restoreInflight) {
|
|
494
|
-
await this.restoreInflight;
|
|
542
|
+
await this.restoreInflight.catch(() => undefined);
|
|
495
543
|
return;
|
|
496
544
|
}
|
|
497
545
|
const initialization = this.restoreSessionsUntracked();
|
|
@@ -501,6 +549,9 @@ export class TerminalManager {
|
|
|
501
549
|
try {
|
|
502
550
|
restored = await initialization;
|
|
503
551
|
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
this.reportDiagnostic({ event: 'terminal.restore_failed', error });
|
|
554
|
+
}
|
|
504
555
|
finally {
|
|
505
556
|
this.pendingInitializations.delete(initialization);
|
|
506
557
|
if (this.restoreInflight === initialization) {
|
|
@@ -509,6 +560,7 @@ export class TerminalManager {
|
|
|
509
560
|
}
|
|
510
561
|
if (restored) {
|
|
511
562
|
this.clearRestoreRetryTimer();
|
|
563
|
+
this.restoreRetryDelayMs = RESTORE_RETRY_INTERVAL_MS;
|
|
512
564
|
}
|
|
513
565
|
else {
|
|
514
566
|
this.scheduleRestoreRetry();
|
|
@@ -527,62 +579,180 @@ export class TerminalManager {
|
|
|
527
579
|
// A close/reset that completed while listExisting() was in flight makes
|
|
528
580
|
// that catalog stale. Retry from a fresh adapter snapshot instead of
|
|
529
581
|
// resurrecting a session the operator just deleted.
|
|
530
|
-
if (restoreGeneration !== this.terminalMutationGeneration) {
|
|
582
|
+
if (restoreGeneration !== this.terminalMutationGeneration || this.shutdownRequested || this.disposed) {
|
|
531
583
|
return false;
|
|
532
584
|
}
|
|
585
|
+
const saved = new Map(this.sessionStore?.list().map(entry => [entry.id, entry]) ?? []);
|
|
586
|
+
const live = new Map(existing.map(session => [session.sessionId, session]));
|
|
533
587
|
const now = this.timestamp();
|
|
534
588
|
for (const session of existing) {
|
|
535
589
|
const sessionId = session.sessionId;
|
|
536
|
-
if (this.terminals.has(sessionId))
|
|
590
|
+
if (this.terminals.has(sessionId) || this.creatingSessionIds.has(sessionId))
|
|
537
591
|
continue;
|
|
592
|
+
const recovery = saved.get(sessionId);
|
|
538
593
|
// Use the adapter-provided title (tmux's window name) when the
|
|
539
594
|
// operator had renamed the tab in a previous run — that's how a
|
|
540
595
|
// human-readable label survives NTerminal restarts. Fall back to a
|
|
541
596
|
// distinguishable placeholder when no name was set.
|
|
542
|
-
const restoredTitle = normalizeTerminalTitle(session.title?.trim() || `Terminal ${sessionId.slice(0, 6)}`);
|
|
543
|
-
this.terminals.set(sessionId, {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
597
|
+
const restoredTitle = normalizeTerminalTitle(session.title?.trim() || recovery?.title || `Terminal ${sessionId.slice(0, 6)}`);
|
|
598
|
+
this.terminals.set(sessionId, this.createRecord({
|
|
599
|
+
id: sessionId,
|
|
600
|
+
title: restoredTitle,
|
|
601
|
+
cwd: session.cwd || recovery?.cwd || this.config.workspaceRoot,
|
|
602
|
+
pid: null,
|
|
603
|
+
cols: recovery?.cols ?? 80,
|
|
604
|
+
rows: recovery?.rows ?? 24,
|
|
605
|
+
createdAt: recovery?.createdAt ?? now,
|
|
606
|
+
updatedAt: now,
|
|
607
|
+
status: 'running',
|
|
608
|
+
exitCode: null,
|
|
609
|
+
...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
|
|
610
|
+
}));
|
|
611
|
+
// A failed deletion may have left its cancellation unwritten. Never
|
|
612
|
+
// destroy a surviving shell merely because an old intent is on disk.
|
|
613
|
+
if (recovery?.deleting)
|
|
614
|
+
this.pendingDeletionCancellations.set(sessionId, Symbol());
|
|
615
|
+
}
|
|
616
|
+
// Only startup-missing panes are eligible for creation. Keep placeholders
|
|
617
|
+
// and their subscribers across retries; ordinary exited shells stay exited.
|
|
618
|
+
if (!this.recoveryReady) {
|
|
619
|
+
for (const entry of saved.values()) {
|
|
620
|
+
if (entry.deleting || entry.creating || this.terminals.has(entry.id))
|
|
621
|
+
continue;
|
|
622
|
+
const record = this.createRecord({
|
|
623
|
+
id: entry.id, title: entry.title, cwd: entry.cwd, cols: entry.cols, rows: entry.rows,
|
|
624
|
+
createdAt: entry.createdAt, updatedAt: now, status: 'exited', exitCode: null, pid: null,
|
|
555
625
|
...(this.terminalCapabilities ? { capabilities: this.terminalCapabilities } : {})
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
626
|
+
});
|
|
627
|
+
this.terminals.set(entry.id, record);
|
|
628
|
+
this.pendingSessionRestores.set(entry.id, record);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// The inventory is authoritative now, including every recoverable missing
|
|
632
|
+
// pane. Slow cwd/spawn/disk operations must not block healthy panes or HTTP.
|
|
633
|
+
this.recoveryReady = true;
|
|
634
|
+
let savedSuccessfully = true;
|
|
635
|
+
for (const entry of saved.values()) {
|
|
636
|
+
if (restoreGeneration !== this.terminalMutationGeneration)
|
|
637
|
+
return false;
|
|
638
|
+
if ((!entry.deleting && !entry.creating) || live.has(entry.id) || this.terminals.has(entry.id) ||
|
|
639
|
+
this.creatingSessionIds.has(entry.id))
|
|
640
|
+
continue;
|
|
641
|
+
if (!await this.forgetRecoverySession(entry.id))
|
|
642
|
+
savedSuccessfully = false;
|
|
643
|
+
}
|
|
644
|
+
await mapWithConcurrency([...this.pendingSessionRestores.values()], 4, record => this.retrySessionRestore(record, live.get(record.summary.id), restoreGeneration));
|
|
645
|
+
if (restoreGeneration !== this.terminalMutationGeneration)
|
|
646
|
+
return false;
|
|
647
|
+
try {
|
|
648
|
+
await this.saveRecoveryMetadata();
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
savedSuccessfully = false;
|
|
652
|
+
this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error });
|
|
653
|
+
}
|
|
654
|
+
if (restoreGeneration !== this.terminalMutationGeneration)
|
|
655
|
+
return false;
|
|
656
|
+
return savedSuccessfully && this.pendingSessionRestores.size === 0 && this.pendingDeletionCancellations.size === 0;
|
|
657
|
+
}
|
|
658
|
+
hasCompleteInventory() {
|
|
659
|
+
return this.recoveryReady && !this.shutdownRequested && !this.disposed;
|
|
660
|
+
}
|
|
661
|
+
isRecoveryPending() {
|
|
662
|
+
return Boolean(this.sessionStore || this.ptyAdapter.listExisting) && !this.recoveryReady;
|
|
663
|
+
}
|
|
664
|
+
async retrySessionRestore(record, live, generation) {
|
|
665
|
+
const id = record.summary.id;
|
|
666
|
+
const canRestore = () => !this.shutdownRequested && !this.disposed && !this.terminalResetBlockers &&
|
|
667
|
+
generation === this.terminalMutationGeneration && !record.closing &&
|
|
668
|
+
this.terminals.get(id) === record && this.pendingSessionRestores.get(id) === record;
|
|
669
|
+
try {
|
|
670
|
+
await this.enqueueControl(record, async () => {
|
|
671
|
+
if (!canRestore())
|
|
672
|
+
return;
|
|
673
|
+
if (live) {
|
|
674
|
+
record.summary.status = 'running';
|
|
675
|
+
record.summary.exitCode = null;
|
|
676
|
+
record.summary.cwd = live.cwd || record.summary.cwd;
|
|
677
|
+
record.summary.updatedAt = this.timestamp();
|
|
678
|
+
this.pendingSessionRestores.delete(id);
|
|
679
|
+
if (record.subscribers.size > 0)
|
|
680
|
+
await this.ensureAttachedAsync(record);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
if (!(await statFile(record.summary.cwd)).isDirectory())
|
|
684
|
+
throw new Error('Saved terminal cwd is not a directory');
|
|
685
|
+
if (!canRestore())
|
|
686
|
+
return;
|
|
687
|
+
const pty = await this.ptyAdapter.spawn({
|
|
688
|
+
sessionId: id, shell: this.config.shell, cwd: record.summary.cwd,
|
|
689
|
+
cols: record.summary.cols, rows: record.summary.rows,
|
|
690
|
+
env: terminalEnv(), title: record.summary.title
|
|
691
|
+
});
|
|
692
|
+
this.pendingSessionRestores.delete(id);
|
|
693
|
+
if (this.disposed) {
|
|
694
|
+
pty.kill();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Close is serialized behind this spawn. If requested while it was
|
|
698
|
+
// pending, leave the new PTY on the same record for close to dispose.
|
|
699
|
+
this.wireUpPty(record, pty);
|
|
700
|
+
}
|
|
701
|
+
if (!record.closing)
|
|
702
|
+
this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
|
|
578
703
|
});
|
|
579
704
|
}
|
|
580
|
-
|
|
705
|
+
catch (error) {
|
|
706
|
+
this.reportDiagnostic({ event: 'terminal.restore_failed', terminalId: id, error });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
assertRecoveryReady() {
|
|
710
|
+
if (this.isRecoveryPending()) {
|
|
711
|
+
throw Object.assign(new Error('terminal_recovery_pending'), { statusCode: 503 });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async saveRecoveryMetadata() {
|
|
715
|
+
for (const [id, token] of this.pendingDeletionCancellations) {
|
|
716
|
+
await this.retryDeletionCancellation(id, token);
|
|
717
|
+
}
|
|
718
|
+
await this.sessionStore?.save([...this.terminals.values()].filter(record => !record.closing).map(record => record.summary));
|
|
719
|
+
}
|
|
720
|
+
scheduleRecoverySave() {
|
|
721
|
+
if (!this.sessionStore || !this.recoveryReady || this.recoveryWriteTimer || this.shutdownRequested || this.disposed)
|
|
722
|
+
return;
|
|
723
|
+
const timer = setTimeout(() => {
|
|
724
|
+
void this.saveRecoveryMetadata()
|
|
725
|
+
.catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }))
|
|
726
|
+
.finally(() => { if (this.recoveryWriteTimer === timer)
|
|
727
|
+
this.recoveryWriteTimer = null; });
|
|
728
|
+
}, RECOVERY_SAVE_DELAY_MS);
|
|
729
|
+
this.recoveryWriteTimer = timer;
|
|
730
|
+
timer.unref?.();
|
|
731
|
+
}
|
|
732
|
+
async checkpointRecovery() {
|
|
733
|
+
if (!this.sessionStore)
|
|
734
|
+
return;
|
|
735
|
+
if (this.recoveryCheckpoint)
|
|
736
|
+
return await this.recoveryCheckpoint;
|
|
737
|
+
const operation = (async () => {
|
|
738
|
+
await mapWithConcurrency([...this.terminals.values()], 4, record => this.refreshCwd(record));
|
|
739
|
+
await this.saveRecoveryMetadata();
|
|
740
|
+
})();
|
|
741
|
+
this.recoveryCheckpoint = operation;
|
|
742
|
+
try {
|
|
743
|
+
await operation;
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
if (this.recoveryCheckpoint === operation)
|
|
747
|
+
this.recoveryCheckpoint = null;
|
|
748
|
+
}
|
|
581
749
|
}
|
|
582
750
|
scheduleRestoreRetry() {
|
|
583
751
|
if (this.restoreRetryTimer || this.shutdownRequested || this.disposed) {
|
|
584
752
|
return;
|
|
585
753
|
}
|
|
754
|
+
const delayMs = this.restoreRetryDelayMs;
|
|
755
|
+
this.restoreRetryDelayMs = Math.min(delayMs * 2, RESTORE_RETRY_MAX_INTERVAL_MS);
|
|
586
756
|
this.restoreRetryTimer = setTimeout(() => {
|
|
587
757
|
this.restoreRetryTimer = null;
|
|
588
758
|
if (this.shutdownRequested || this.disposed) {
|
|
@@ -593,7 +763,7 @@ export class TerminalManager {
|
|
|
593
763
|
return;
|
|
594
764
|
}
|
|
595
765
|
void this.restoreSessions();
|
|
596
|
-
},
|
|
766
|
+
}, delayMs);
|
|
597
767
|
this.restoreRetryTimer.unref?.();
|
|
598
768
|
}
|
|
599
769
|
clearRestoreRetryTimer() {
|
|
@@ -613,6 +783,11 @@ export class TerminalManager {
|
|
|
613
783
|
record.summary.status = 'running';
|
|
614
784
|
record.summary.exitCode = null;
|
|
615
785
|
record.summary.updatedAt = this.timestamp();
|
|
786
|
+
record.summary.capabilities = {
|
|
787
|
+
...record.summary.capabilities,
|
|
788
|
+
screenRedraw: typeof pty.redrawScreen === 'function',
|
|
789
|
+
screenRestore: typeof pty.redrawScreen === 'function' && typeof this.ptyAdapter.cancelScrollbackMode === 'function'
|
|
790
|
+
};
|
|
616
791
|
record.dataDisposable = pty.onData((data) => {
|
|
617
792
|
record.output.push(data);
|
|
618
793
|
record.lastOutputAt = Date.now();
|
|
@@ -730,6 +905,7 @@ export class TerminalManager {
|
|
|
730
905
|
this.wireUpPty(record, pty);
|
|
731
906
|
}
|
|
732
907
|
listTerminals() {
|
|
908
|
+
this.assertRecoveryReady();
|
|
733
909
|
return [...this.terminals.values()].map((record) => cloneSummary(record.summary));
|
|
734
910
|
}
|
|
735
911
|
subscribeActivity(listener) {
|
|
@@ -1183,6 +1359,7 @@ export class TerminalManager {
|
|
|
1183
1359
|
? resolution : null;
|
|
1184
1360
|
}
|
|
1185
1361
|
getTerminal(id) {
|
|
1362
|
+
this.assertRecoveryReady();
|
|
1186
1363
|
const record = this.terminals.get(id);
|
|
1187
1364
|
return record ? cloneSummary(record.summary) : null;
|
|
1188
1365
|
}
|
|
@@ -1409,6 +1586,7 @@ export class TerminalManager {
|
|
|
1409
1586
|
// name). The adapter write and in-memory update share the terminal's control
|
|
1410
1587
|
// queue so rename, input, scrollback, and close cannot overtake each other.
|
|
1411
1588
|
async renameTerminal(id, title) {
|
|
1589
|
+
this.assertRecoveryReady();
|
|
1412
1590
|
const record = this.terminals.get(id);
|
|
1413
1591
|
if (!record)
|
|
1414
1592
|
return { status: 'not_found' };
|
|
@@ -1443,6 +1621,14 @@ export class TerminalManager {
|
|
|
1443
1621
|
// close later fails, memory and tmux must not diverge.
|
|
1444
1622
|
record.summary.title = normalizedTitle;
|
|
1445
1623
|
record.summary.updatedAt = this.timestamp();
|
|
1624
|
+
try {
|
|
1625
|
+
await this.sessionStore?.save([record.summary]);
|
|
1626
|
+
}
|
|
1627
|
+
catch (error) {
|
|
1628
|
+
// tmux already accepted the rename. Keep memory/UI consistent and
|
|
1629
|
+
// retry its recovery checkpoint rather than reporting a false rollback.
|
|
1630
|
+
this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
|
|
1631
|
+
}
|
|
1446
1632
|
const terminal = cloneSummary(record.summary);
|
|
1447
1633
|
this.publish(record, { type: 'terminal_updated', terminal });
|
|
1448
1634
|
return { status: 'renamed', terminal };
|
|
@@ -1459,6 +1645,7 @@ export class TerminalManager {
|
|
|
1459
1645
|
: null;
|
|
1460
1646
|
}
|
|
1461
1647
|
attachTerminal(id, subscriber, options = {}) {
|
|
1648
|
+
this.assertRecoveryReady();
|
|
1462
1649
|
const record = this.terminals.get(id);
|
|
1463
1650
|
if (!record || record.closing) {
|
|
1464
1651
|
return null;
|
|
@@ -1474,6 +1661,7 @@ export class TerminalManager {
|
|
|
1474
1661
|
return this.createAttachment(record, subscriber, options);
|
|
1475
1662
|
}
|
|
1476
1663
|
async attachTerminalAsync(id, subscriber, options = {}) {
|
|
1664
|
+
this.assertRecoveryReady();
|
|
1477
1665
|
const record = this.terminals.get(id);
|
|
1478
1666
|
if (!record || record.closing) {
|
|
1479
1667
|
return null;
|
|
@@ -1737,6 +1925,7 @@ export class TerminalManager {
|
|
|
1737
1925
|
return true;
|
|
1738
1926
|
}
|
|
1739
1927
|
async closeTerminal(id) {
|
|
1928
|
+
this.assertRecoveryReady();
|
|
1740
1929
|
const record = this.terminals.get(id);
|
|
1741
1930
|
if (!record) {
|
|
1742
1931
|
return false;
|
|
@@ -1750,6 +1939,16 @@ export class TerminalManager {
|
|
|
1750
1939
|
const queuedClose = this.enqueueControl(record, async () => {
|
|
1751
1940
|
if (this.terminals.get(id) !== record)
|
|
1752
1941
|
return false;
|
|
1942
|
+
try {
|
|
1943
|
+
await this.sessionStore?.setDeleting(id, true);
|
|
1944
|
+
}
|
|
1945
|
+
catch (error) {
|
|
1946
|
+
// Even an already-missing shell must keep its recovery entry if the
|
|
1947
|
+
// deletion intent could not be committed.
|
|
1948
|
+
await this.cancelFailedClose(record);
|
|
1949
|
+
this.reportDiagnostic({ event: 'terminal.close_failed', terminalId: id, error });
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1753
1952
|
if (this.ptyAdapter.destroySession) {
|
|
1754
1953
|
let destroyed = false;
|
|
1755
1954
|
try {
|
|
@@ -1764,7 +1963,10 @@ export class TerminalManager {
|
|
|
1764
1963
|
}
|
|
1765
1964
|
this.disposeRecord(record);
|
|
1766
1965
|
this.terminals.delete(id);
|
|
1966
|
+
this.terminalMutationGeneration += 1;
|
|
1967
|
+
this.pendingSessionRestores.delete(id);
|
|
1767
1968
|
this.publishRemovedActivity(record);
|
|
1969
|
+
await this.forgetRecoverySession(id);
|
|
1768
1970
|
return true;
|
|
1769
1971
|
}, 0, true);
|
|
1770
1972
|
const close = queuedClose.catch(async (error) => {
|
|
@@ -1790,6 +1992,7 @@ export class TerminalManager {
|
|
|
1790
1992
|
if (record.closing)
|
|
1791
1993
|
return;
|
|
1792
1994
|
record.closing = true;
|
|
1995
|
+
this.pendingDeletionCancellations.delete(record.summary.id);
|
|
1793
1996
|
this.terminalMutationGeneration += 1;
|
|
1794
1997
|
record.transcriptGeneration += 1;
|
|
1795
1998
|
this.sealPendingScroll(record);
|
|
@@ -1797,28 +2000,70 @@ export class TerminalManager {
|
|
|
1797
2000
|
this.cancelQueuedTranscriptRefresh(record);
|
|
1798
2001
|
}
|
|
1799
2002
|
async recoverOrFinalizeFailedClose(record) {
|
|
1800
|
-
record.closing = false;
|
|
1801
2003
|
if (record.pty) {
|
|
2004
|
+
await this.cancelFailedClose(record);
|
|
1802
2005
|
return false;
|
|
1803
2006
|
}
|
|
1804
2007
|
const persistentState = await this.persistentSessionState(record.summary.id);
|
|
1805
2008
|
if (persistentState === false) {
|
|
1806
2009
|
this.disposeRecord(record);
|
|
1807
2010
|
this.terminals.delete(record.summary.id);
|
|
2011
|
+
this.terminalMutationGeneration += 1;
|
|
2012
|
+
this.pendingSessionRestores.delete(record.summary.id);
|
|
1808
2013
|
this.publishRemovedActivity(record);
|
|
2014
|
+
await this.forgetRecoverySession(record.summary.id);
|
|
1809
2015
|
return true;
|
|
1810
2016
|
}
|
|
1811
|
-
record.summary.
|
|
1812
|
-
|
|
2017
|
+
if (!this.pendingSessionRestores.has(record.summary.id)) {
|
|
2018
|
+
record.summary.status = 'running';
|
|
2019
|
+
record.summary.exitCode = null;
|
|
2020
|
+
}
|
|
2021
|
+
await this.cancelFailedClose(record);
|
|
2022
|
+
return false;
|
|
2023
|
+
}
|
|
2024
|
+
async cancelFailedClose(record) {
|
|
2025
|
+
// Disk errors must not leave a surviving terminal permanently unwritable.
|
|
2026
|
+
record.closing = false;
|
|
1813
2027
|
record.summary.updatedAt = this.timestamp();
|
|
1814
|
-
if (record.subscribers.size > 0) {
|
|
2028
|
+
if (!record.pty && record.summary.status === 'running' && record.subscribers.size > 0) {
|
|
1815
2029
|
void this.ensureAttachedAsync(record);
|
|
1816
2030
|
}
|
|
1817
2031
|
this.publish(record, {
|
|
1818
2032
|
type: 'terminal_updated',
|
|
1819
2033
|
terminal: cloneSummary(record.summary)
|
|
1820
2034
|
});
|
|
1821
|
-
|
|
2035
|
+
if (this.sessionStore) {
|
|
2036
|
+
const token = Symbol();
|
|
2037
|
+
this.pendingDeletionCancellations.set(record.summary.id, token);
|
|
2038
|
+
await this.retryDeletionCancellation(record.summary.id, token);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
async retryDeletionCancellation(id, token) {
|
|
2042
|
+
if (this.pendingDeletionCancellations.get(id) !== token || this.terminals.get(id)?.closing)
|
|
2043
|
+
return;
|
|
2044
|
+
try {
|
|
2045
|
+
// The store serializes writes. A later explicit delete is enqueued after
|
|
2046
|
+
// this cancellation, and invalidates its token before either can finish.
|
|
2047
|
+
await this.sessionStore?.setDeleting(id, false);
|
|
2048
|
+
if (this.pendingDeletionCancellations.get(id) === token)
|
|
2049
|
+
this.pendingDeletionCancellations.delete(id);
|
|
2050
|
+
}
|
|
2051
|
+
catch (error) {
|
|
2052
|
+
this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
|
|
2053
|
+
this.scheduleRestoreRetry();
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
async forgetRecoverySession(id) {
|
|
2057
|
+
try {
|
|
2058
|
+
await this.sessionStore?.forget(id);
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
catch (error) {
|
|
2062
|
+
// The durable deletion intent remains sufficient to prevent restoration.
|
|
2063
|
+
this.reportDiagnostic({ event: 'terminal.recovery_save_failed', terminalId: id, error });
|
|
2064
|
+
this.scheduleRestoreRetry();
|
|
2065
|
+
return false;
|
|
2066
|
+
}
|
|
1822
2067
|
}
|
|
1823
2068
|
async closeAll() {
|
|
1824
2069
|
this.shutdownRequested = true;
|
|
@@ -1839,6 +2084,17 @@ export class TerminalManager {
|
|
|
1839
2084
|
}
|
|
1840
2085
|
await Promise.allSettled(pendingCloses);
|
|
1841
2086
|
}
|
|
2087
|
+
await this.checkpointRecovery().catch(error => this.reportDiagnostic({ event: 'terminal.recovery_save_failed', error }));
|
|
2088
|
+
// Requests can still arrive while the checkpoint awaits disk/cwd I/O.
|
|
2089
|
+
// Drain again, with no await between the last empty check and disposal.
|
|
2090
|
+
while (true) {
|
|
2091
|
+
const pendingCloses = [...this.terminals.values()]
|
|
2092
|
+
.map(record => record.closePromise)
|
|
2093
|
+
.filter((close) => close !== null);
|
|
2094
|
+
if (pendingCloses.length === 0)
|
|
2095
|
+
break;
|
|
2096
|
+
await Promise.allSettled(pendingCloses);
|
|
2097
|
+
}
|
|
1842
2098
|
for (const record of this.terminals.values()) {
|
|
1843
2099
|
this.disposeRecord(record);
|
|
1844
2100
|
}
|
|
@@ -1975,11 +2231,14 @@ export class TerminalManager {
|
|
|
1975
2231
|
}
|
|
1976
2232
|
async refreshCwd(record) {
|
|
1977
2233
|
const pty = record.pty;
|
|
1978
|
-
if (
|
|
2234
|
+
if (record.closing || record.summary.status !== 'running') {
|
|
1979
2235
|
return;
|
|
1980
2236
|
}
|
|
1981
2237
|
const refreshGeneration = ++record.cwdRefreshGeneration;
|
|
1982
|
-
|
|
2238
|
+
// Detached panes have no local tmux client. Read their backing shell PID
|
|
2239
|
+
// without attaching a client, so cwd changes over SSH are checkpointed too.
|
|
2240
|
+
const pid = pty?.pid ?? await this.ptyAdapter.getSessionPid?.(record.summary.id);
|
|
2241
|
+
const cwd = pid ? await this.safeResolveCwd(pid) : null;
|
|
1983
2242
|
if (!cwd ||
|
|
1984
2243
|
this.terminals.get(record.summary.id) !== record ||
|
|
1985
2244
|
record.closing ||
|
|
@@ -2026,6 +2285,8 @@ export class TerminalManager {
|
|
|
2026
2285
|
}).catch(() => undefined);
|
|
2027
2286
|
}
|
|
2028
2287
|
publish(record, message) {
|
|
2288
|
+
if (message.type === 'terminal_updated')
|
|
2289
|
+
this.scheduleRecoverySave();
|
|
2029
2290
|
for (const subscriber of record.subscribers) {
|
|
2030
2291
|
subscriber(message);
|
|
2031
2292
|
}
|