Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/applicationShutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function harness() {
disposeWorkflowBridge: vi.fn(async (): Promise<void> => undefined),
disposeCaffeinate: vi.fn(async (): Promise<void> => undefined),
stopHeapWatchdog: vi.fn(async (): Promise<void> => undefined),
stopDetachedTmuxSweep: vi.fn(async (): Promise<void> => undefined),
drainWorkspace: vi.fn(async (): Promise<void> => undefined),
drainDictationHistory: vi.fn(async (): Promise<void> => undefined),
flushGhosts: vi.fn(async (): Promise<void> => undefined),
Expand Down
11 changes: 10 additions & 1 deletion src/main/applicationShutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface ApplicationShutdownServices {
disposeWorkflowBridge: Stop
disposeCaffeinate: Stop
stopHeapWatchdog: Stop
/** The periodic detached-tmux reaper (#1030 item 4). Stopping it is pure
* timer teardown — it must NEVER kill tmux sessions at quit, because those
* shells are exactly what the next launch recovers terminals from. */
stopDetachedTmuxSweep: Stop
drainWorkspace: Stop
drainDictationHistory: Stop
flushGhosts: Stop
Expand Down Expand Up @@ -105,6 +109,11 @@ export function installApplicationShutdown(options: {
async function drain(): Promise<void> {
for (const [name, stage] of stages) if (stage.state === 'failed') stages.delete(name)
const earlyStops = stopExecution()
// Disarmed FIRST, before anything that can stall: it is the one shutdown
// stage that is purely about NOT doing something. Leaving it armed behind a
// hung provider stop would let a five-minute tick fire during quit and kill
// the tmux sessions the next launch recovers terminals from (#1030 item 4).
const detachedTmuxSweepStop = run('detached-tmux-sweep', services.stopDetachedTmuxSweep)
const dictationStop = run('dictation', services.stopDictation)
// Startup checks the committed gate after asynchronous acquisition. Its
// settlement closes the resource inventory; a missing SessionManager alone
Expand All @@ -113,7 +122,7 @@ export function installApplicationShutdown(options: {
// Extensions stop only after startup settles: startup creates the
// runtime, and `run` keeps the first receipt, so an early stop against a
// not-yet-published runtime would complete and let the real one escape.
await join([...earlyStops, ...stopExecution(), dictationStop, run('extensions', services.stopExtensions)])
await join([...earlyStops, ...stopExecution(), detachedTmuxSweepStop, dictationStop, run('extensions', services.stopExtensions)])
await run('observations', services.flushObservations)
await run('proxy-sweep', services.sweepOwnedProxies)

Expand Down
44 changes: 44 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import {
} from '@main/pasteDebugJournal.js'
import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js'
import { reconcileWorkspace } from '@main/tmux/tmuxRecovery.js'
import { startDetachedTerminalSweep } from '@main/tmux/detachedSweep.js'
import type { DetachedSweepSchedule } from '@main/tmux/detachedSweep.js'

import {
handleExtensionScheme,
Expand Down Expand Up @@ -277,6 +279,8 @@ let manager: SessionManager | null = null
let remoteController: RemoteController | null = null
let remoteWorkspaceProjection: RemoteWorkspaceProjection | null = null
let tmuxRegistry: TmuxRegistry | null = null
// The running-app tmux reaper's timer handle, so quit can stop it (#1030).
let detachedTmuxSweep: DetachedSweepSchedule | null = null
let stateProcessLock: Extract<StateProcessLock, { acquired: true }> | null = null
let appRunJournal: AppRunJournal | null = null
let workflowService: WorkflowService | null = null
Expand Down Expand Up @@ -945,6 +949,45 @@ async function startApp(): Promise<void> {
systemSuspension.on('suspension', (suspension: import('@shared/types/systemSuspension.js').SystemSuspension) => {
manager?.noteSystemSuspension(suspension)
})

// The running-app half of tmux cleanup (#1030 item 4). Startup reconciliation
// above reaps what a previous run left behind; this reaps what THIS run
// leaves behind, once the undo window that kept each shell alive has closed.
// Armed only when tmux is available and only after the manager exists,
// because the manager is one of the two authorities the sweep consults — the
// other is the persisted workspace file, read through the same decoder
// startup uses.
if (tmuxAvailable && manager) {
const sweepingManager = manager
detachedTmuxSweep = startDetachedTerminalSweep({
registry: tmuxRegistry,
readWorkspace: () => readFile(STATE_FILE, 'utf8'),
liveTmuxNames: () => sweepingManager.getLiveTmuxNames(),
onSweep: report => {
// Only worth a line when something actually happened; a five-minute
// heartbeat saying "nothing" would bury the log.
if (report.reaped.length === 0) return
console.log(`[tmux] reaped ${report.reaped.length} detached terminal session(s)`)
appRunJournal?.record({
area: 'app.tmux',
name: 'tmux.detached.reaped',
data: { reaped: report.reaped.length, pending: report.pending.length },
})
},
onError: error => {
// Never fatal: a failed sweep is a leak that gets another chance in
// five minutes, not a reason to stop cleaning for the rest of the run.
performanceService.error('app.tmux.detachedSweep.error', error)
appRunJournal?.recordError('tmux.detached_sweep.error', error)
},
})
// Ownership published on the tick it becomes true. Every authority the
// sweep reads is a snapshot taken before an await; this is the one signal
// that cannot be stale, and it is what keeps an Undo Close restore from
// being killed by a scan that started before it.
const sweepAttachments = detachedTmuxSweep
manager.onTmuxAttached(name => sweepAttachments.noteAttached(name))
}
// Project ownership lives in renderer state, while backend/transcript facts
// live in SessionManager. Construct this bridge only after both the MCP host
// and manager exist so tool calls cannot observe a half-wired authority.
Expand Down Expand Up @@ -1421,6 +1464,7 @@ const sessionShutdownGate = installApplicationShutdown({
disposeWorkflowBridge: () => workflowBridge?.dispose(),
disposeCaffeinate: () => caffeinateController.dispose(),
stopHeapWatchdog: stopMainHeapWatchdog,
stopDetachedTmuxSweep: () => { detachedTmuxSweep?.stop(); detachedTmuxSweep = null },
drainWorkspace: () => shutdownWorkspaceStore?.drainAdmittedWrites(),
drainDictationHistory: flushHistoryWrites,
flushGhosts: () => ghostJournals.flushAll(),
Expand Down
47 changes: 47 additions & 0 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,8 @@ export class SessionManager extends EventEmitter {
timer?: ReturnType<typeof setTimeout>
}>()
private readonly lastActivityAt = new Map<string, number>()
/** Subscribers to tmux ownership becoming true; see onTmuxAttached. */
private readonly tmuxAttachListeners = new Set<(tmuxName: string) => void>()
// Latest per-session UI-state snapshots, cached at the emit sites below.
// WHY: consumers that attach mid-flight (the remote mobile companion's
// SessionFeedSource/RemoteServer — enabled long after sessions started —
Expand Down Expand Up @@ -3182,6 +3184,11 @@ export class SessionManager extends EventEmitter {
})

this.sessions.set(sessionId, terminalEntry)
// Publish ownership at the moment it becomes true, on the same tick. The
// detached sweep uses this to disqualify a name from an in-flight scan's
// kills, and to reset a retention clock the next sweep would otherwise
// still be counting from a PREVIOUS close (#1030 item 4 review).
this.noteTmuxAttached(tmuxSessionName)
this.rememberSessionId(sessionId)
this.throwIfSpawnCancelled(recoveryClaim, codexReplacementHandoff)
try {
Expand Down Expand Up @@ -4925,6 +4932,46 @@ export class SessionManager extends EventEmitter {
return this.terminalForeground.snapshot()
}

/**
* Called synchronously whenever a tmux session is bound to a live terminal
* entry. The detached sweep subscribes; see its `noteAttached`.
*
* WHY a listener rather than the sweep polling `getLiveTmuxNames()`: every
* answer the sweep computes is a snapshot taken before an await, and an Undo
* Close restore can land inside one. This is the one signal that cannot be
* stale, because it fires on the same tick as the registration.
*/
onTmuxAttached(listener: (tmuxName: string) => void): () => void {
this.tmuxAttachListeners.add(listener)
return () => { this.tmuxAttachListeners.delete(listener) }
}

private noteTmuxAttached(tmuxName: string | null): void {
if (!tmuxName) return
for (const listener of this.tmuxAttachListeners) {
// A listener must never be able to fail a terminal spawn.
try { listener(tmuxName) } catch { /* diagnostics only */ }
}
}

/**
* Every tmux session name main currently owns a live terminal for.
*
* The detached sweep (`tmux/detachedSweep.ts`) needs this as the second
* authority beside the persisted workspace file: autosave is debounced, so a
* terminal created seconds ago is live here and absent from the file, and the
* file alone would read it as an orphan and kill the user's brand-new shell.
* Read live from the registry rather than cached — a snapshot taken before an
* await is exactly the stale evidence that would authorize that kill.
*/
getLiveTmuxNames(): string[] {
const names: string[] = []
for (const entry of this.sessions.values()) {
if (entry.kind === 'terminal' && entry.tmuxName) names.push(entry.tmuxName)
}
return names
}

/** Kill every live session. Called on app quit. */
async killAll(): Promise<void> {
this.shuttingDown = true
Expand Down
Loading
Loading