Skip to content

Commit 8be286d

Browse files
committed
fix(desktop): record a tmux run before its command may start, and never lose a record tmux could not answer for
- A tagged run's record is saved before its start gate opens; a run whose record could not be saved (no socket, or the write failed) never starts. - Records are written atomically; an unfinished write's temporary file is cleaned up, and a record that cannot be removed is logged and skipped rather than failing the sweep or the call. - Stopping a recorded run reports `unknown` whenever tmux could not answer, so its record stays for the next sweep; its pane is closed only while it is confirmed the run's. - A service rebuilt after a failed restore records its runs too, and a run that finished before its terminal closed is forgotten then.
1 parent e48dede commit 8be286d

8 files changed

Lines changed: 234 additions & 41 deletions

File tree

‎apps/desktop/src/main/terminal/index.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
killPane,
5151
listPanes,
5252
pollRun,
53+
type RecordedRun,
5354
resolveAttachment,
5455
runPaneState,
5556
sendKey,
@@ -536,7 +537,8 @@ export class TerminalService {
536537
const pending = this.pendingRuns.get(terminalId)
537538
if (!pending) return
538539
for (const handle of pending) {
539-
// An untracked run is never stopped, so there is nothing to keep it for.
540+
// A finished run needs no record; an untracked one is never stopped, so is not kept either.
541+
if (isRunComplete(handle)) this.forgetRun(handle)
540542
if (env && handle.runId !== null && !isRunComplete(handle)) this.orphanedRuns.set(handle, env)
541543
this.releaseRun(handle)
542544
}
@@ -1292,15 +1294,11 @@ export class TerminalService {
12921294

12931295
const started = Date.now()
12941296
await this.reapFinishedRuns(terminal.terminalId, terminal.env)
1295-
const handle = await startRun(session, command, terminal.currentCwd, terminal.env)
1297+
const ledger = this.options.runLedger
1298+
const handle = await startRun(session, command, terminal.currentCwd, terminal.env, {
1299+
...(ledger ? { beforeStart: (run: RecordedRun) => ledger.record(run) } : {}),
1300+
})
12961301
if ('error' in handle) throw new TerminalError('SPAWN_FAILED', handle.error)
1297-
if (handle.runId && handle.socket) {
1298-
this.options.runLedger?.record({
1299-
runId: handle.runId,
1300-
pane: handle.pane,
1301-
socket: handle.socket,
1302-
})
1303-
}
13041302
// Tracked from the moment its window exists, so sign-out can stop it even mid-wait.
13051303
const pending = this.pendingRuns.get(terminal.terminalId)
13061304
if (pending) pending.push(handle)

‎apps/desktop/src/main/terminal/registry.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { tmpdir } from 'node:os'
2+
import { join } from 'node:path'
23
import type { TerminalCommandEvent } from '@sim/terminal-protocol'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
45

@@ -89,11 +90,13 @@ vi.mock('@/main/terminal/session', () => ({
8990
},
9091
}))
9192

93+
import { TerminalService, type TerminalServiceOptions } from '@/main/terminal'
9294
import {
9395
type ScopedTerminalSink,
9496
TerminalRegistry,
9597
type TerminalScopePersistence,
9698
} from '@/main/terminal/registry'
99+
import { createRunLedger } from '@/main/terminal/run-ledger'
97100

98101
function registry(): TerminalRegistry {
99102
return new TerminalRegistry()
@@ -186,6 +189,32 @@ describe('TerminalRegistry', () => {
186189
terminals.dispose()
187190
})
188191

192+
it('gives a service rebuilt after a failed restore the run ledger too', () => {
193+
const ledger = createRunLedger(join(tmpdir(), `sim-registry-ledger-${process.pid}`))
194+
const built: Array<TerminalServiceOptions['runLedger']> = []
195+
const persistence: TerminalScopePersistence = {
196+
load: () => ({ v: 1 as const, tabs: [{ cwd: tmpdir() }, { cwd: tmpdir() }], activeIndex: 0 }),
197+
save: () => true,
198+
migrate: () => true,
199+
disposeScope: () => {},
200+
}
201+
const terminals = new TerminalRegistry(
202+
persistence,
203+
(_scope, options) => {
204+
built.push(options.runLedger)
205+
return new TerminalService(options)
206+
},
207+
ledger
208+
)
209+
createControl.failAt = 2
210+
211+
expect(() => terminals.restoreScope('chat-A')).toThrow('PTY spawn failed')
212+
213+
// The service that failed to restore, and the one built in its place.
214+
expect(built).toHaveLength(2)
215+
expect(built.every((runLedger) => runLedger === ledger)).toBe(true)
216+
})
217+
189218
it('rolls back a partial restore before retrying the complete descriptor', () => {
190219
const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }]
191220
const persistence: TerminalScopePersistence = {

‎apps/desktop/src/main/terminal/registry.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ export class TerminalRegistry {
486486
replacement = this.serviceFactory(entry.scope, {
487487
loadCwd: () => entry.persisted?.tabs[0]?.cwd,
488488
canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS,
489+
runLedger: this.runLedger,
489490
})
490491
} catch {
491492
this.entries.delete(entry.scope)

‎apps/desktop/src/main/terminal/run-ledger.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { afterEach, describe, expect, it } from 'vitest'
@@ -53,6 +53,37 @@ describe('the tmux run ledger', () => {
5353
expect(readdirSync(dir)).toEqual(['run-1.json'])
5454
})
5555

56+
it('cleans up after a write that never finished, keeping the saved record', () => {
57+
const dir = scratch()
58+
const ledger = createRunLedger(dir)
59+
ledger.record(RUN)
60+
writeFileSync(join(dir, 'run-2.json.4242.1.tmp'), '{"runId":"run-2","pa')
61+
62+
expect(ledger.list()).toEqual([RUN])
63+
expect(readdirSync(dir)).toEqual(['run-1.json'])
64+
})
65+
66+
it('keeps sweeping, and lets a call finish, when an entry cannot be removed', () => {
67+
const dir = scratch()
68+
const ledger = createRunLedger(dir)
69+
ledger.record(RUN)
70+
// Not a file, so removing it fails.
71+
mkdirSync(join(dir, 'run-3.json'))
72+
mkdirSync(join(dir, 'run-4.json'))
73+
74+
expect(ledger.list()).toEqual([RUN])
75+
expect(() => ledger.forget('run-4')).not.toThrow()
76+
})
77+
78+
it('reports a record it could not save, so the run is not started', () => {
79+
const dir = scratch()
80+
// A file where the directory should be: nothing can be saved under it.
81+
writeFileSync(join(dir, '..', 'blocked'), '')
82+
const ledger = createRunLedger(join(dir, '..', 'blocked'))
83+
84+
expect(ledger.record(RUN)).toBe(false)
85+
})
86+
5687
it('records nothing for a run tag that is not a plain id', () => {
5788
const dir = scratch()
5889
const ledger = createRunLedger(dir)

‎apps/desktop/src/main/terminal/run-ledger.ts‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,33 @@
55
* going in the user's tmux server, and the next launch would otherwise know nothing about it.
66
* Each record names the run's tag, its pane and its tmux server's socket, and nothing else: no
77
* command line and no output, since it lives outside the account's encrypted data. A record is
8-
* written once the pane is tagged and removed once the run has ended, its pane is gone, or Sim has
9-
* stopped it.
8+
* saved before the run's command may start, and removed once the run has ended, its pane is gone,
9+
* or Sim has stopped it.
1010
*/
1111

12-
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12+
import { readdirSync, readFileSync, rmSync } from 'node:fs'
1313
import { join } from 'node:path'
1414
import { createLogger } from '@sim/logger'
1515
import { getErrorMessage } from '@sim/utils/errors'
16+
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
17+
import type { RecordedRun } from '@/main/terminal/tmux'
1618

1719
const logger = createLogger('DesktopTerminalRunLedger')
1820

19-
/** One recorded run: what it takes to find its pane again, on the server it ran on. */
20-
export interface RunRecord {
21-
/** The tag on the run's pane; only a pane carrying it is ever acted on. */
22-
runId: string
23-
pane: string
24-
/** The tmux server's socket, so a different server is never asked about this pane. */
25-
socket: string
26-
}
27-
2821
export interface RunLedger {
29-
record(run: RunRecord): void
22+
/** Saves a run's record; false when it could not be saved, so the run must not start. */
23+
record(run: RecordedRun): boolean
3024
forget(runId: string): void
3125
/** Every recorded run; `excludeLive` leaves out runs this process recorded. */
32-
list(options?: { excludeLive?: boolean }): RunRecord[]
26+
list(options?: { excludeLive?: boolean }): RecordedRun[]
3327
}
3428

3529
/** Run tags are generated ids; anything else in the directory is not a record. */
3630
const RUN_ID = /^[A-Za-z0-9_-]{1,128}$/
3731

38-
function parseRecord(text: string): RunRecord | null {
32+
function parseRecord(text: string): RecordedRun | null {
3933
try {
40-
const parsed = JSON.parse(text) as Partial<RunRecord>
34+
const parsed = JSON.parse(text) as Partial<RecordedRun>
4135
if (
4236
typeof parsed.runId === 'string' &&
4337
RUN_ID.test(parsed.runId) &&
@@ -54,26 +48,35 @@ function parseRecord(text: string): RunRecord | null {
5448
return null
5549
}
5650

51+
/** Removes a file the ledger no longer needs; a failure is logged, never thrown at a caller. */
52+
function remove(path: string): void {
53+
try {
54+
rmSync(path, { force: true })
55+
} catch (error) {
56+
logger.warn('Could not remove a tmux run record', { error: getErrorMessage(error) })
57+
}
58+
}
59+
5760
export function createRunLedger(dir: string): RunLedger {
5861
/** Runs recorded by this process, still going as far as it knows. */
5962
const live = new Set<string>()
6063
const pathFor = (runId: string) => join(dir, `${runId}.json`)
6164

6265
return {
6366
record(run) {
64-
if (!RUN_ID.test(run.runId)) return
67+
if (!RUN_ID.test(run.runId)) return false
6568
try {
66-
mkdirSync(dir, { recursive: true, mode: 0o700 })
67-
writeFileSync(pathFor(run.runId), JSON.stringify(run), { mode: 0o600 })
69+
writeJsonFileAtomicallySync(pathFor(run.runId), run)
6870
live.add(run.runId)
71+
return true
6972
} catch (error) {
7073
logger.warn('Could not record a tmux run', { error: getErrorMessage(error) })
74+
return false
7175
}
7276
},
7377
forget(runId) {
7478
live.delete(runId)
75-
if (!RUN_ID.test(runId)) return
76-
rmSync(pathFor(runId), { force: true })
79+
if (RUN_ID.test(runId)) remove(pathFor(runId))
7780
},
7881
list(options = {}) {
7982
let names: string[]
@@ -82,18 +85,23 @@ export function createRunLedger(dir: string): RunLedger {
8285
} catch {
8386
return []
8487
}
85-
const runs: RunRecord[] = []
88+
const runs: RecordedRun[] = []
8689
for (const name of names) {
90+
// A write that never finished leaves only its temporary file behind.
91+
if (name.endsWith('.tmp')) {
92+
remove(join(dir, name))
93+
continue
94+
}
8795
if (!name.endsWith('.json')) continue
88-
let record: RunRecord | null = null
96+
let record: RecordedRun | null = null
8997
try {
9098
record = parseRecord(readFileSync(join(dir, name), 'utf8'))
9199
} catch {
92100
record = null
93101
}
94102
if (!record || `${record.runId}.json` !== name) {
95103
// Nothing could act on it safely; it only takes up space.
96-
rmSync(join(dir, name), { force: true })
104+
remove(join(dir, name))
97105
continue
98106
}
99107
if (options.excludeLive && live.has(record.runId)) continue

‎apps/desktop/src/main/terminal/service.test.ts‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,29 @@ vi.mock('@/main/terminal/tmux', async () => {
3737
: actual.resolveAttachment(pid, env),
3838
startRun: async (...args: Parameters<typeof actual.startRun>) => {
3939
if (!tmuxFake.on) return actual.startRun(...args)
40-
const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-fake-'))
40+
const options = args[4]
4141
const pane = `%${nextPane++}`
42+
const runId = tmuxFake.untracked ? null : `run-${pane.slice(1)}`
43+
const socket = tmuxFake.untracked ? null : '/tmp/tmux-fake/default'
44+
// Like the real one, a tagged run starts only once its record is saved.
45+
if (
46+
runId &&
47+
socket &&
48+
options?.beforeStart &&
49+
!options.beforeStart({ runId, pane, socket })
50+
) {
51+
return { error: 'The command could not be recorded for a later stop, so it was not run.' }
52+
}
53+
const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-fake-'))
4254
const statusPath = join(dir, 'status')
4355
writeFileSync(join(dir, 'out'), 'partial output')
4456
tmuxFake.statusPaths.set(pane, statusPath)
4557
tmuxFake.open.add(pane)
4658
return {
4759
window: `@${pane.slice(1)}`,
4860
pane,
49-
runId: tmuxFake.untracked ? null : `run-${pane.slice(1)}`,
50-
socket: tmuxFake.untracked ? null : '/tmp/tmux-fake/default',
61+
runId,
62+
socket,
5163
outPath: join(dir, 'out'),
5264
statusPath,
5365
dispose: () => rmSync(dir, { recursive: true, force: true }),
@@ -628,7 +640,8 @@ describe('agent commands in tmux', () => {
628640
it('keeps a record of a tagged run exactly as long as the run goes on', async () => {
629641
tmuxFake.on = true
630642
tmuxFake.statusPaths.clear()
631-
const ledgerDir = join(mkdtempSync(join(tmpdir(), 'sim-ledger-')), 'terminal-runs')
643+
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
644+
const ledgerDir = join(scratch, 'terminal-runs')
632645
const ledger = createRunLedger(ledgerDir)
633646
try {
634647
const terminal = new TerminalService({ loadCwd: () => '/tmp', runLedger: ledger })
@@ -652,6 +665,54 @@ describe('agent commands in tmux', () => {
652665
).toEqual([[...tmuxFake.statusPaths.keys()][1]])
653666
} finally {
654667
tmuxFake.on = false
668+
rmSync(scratch, { recursive: true, force: true })
669+
}
670+
})
671+
672+
it('forgets a run that finished before its terminal closed', async () => {
673+
tmuxFake.on = true
674+
tmuxFake.statusPaths.clear()
675+
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
676+
const ledgerDir = join(scratch, 'terminal-runs')
677+
try {
678+
const terminal = new TerminalService({
679+
loadCwd: () => '/tmp',
680+
runLedger: createRunLedger(ledgerDir),
681+
})
682+
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
683+
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
684+
const [[, statusPath = ''] = []] = [...tmuxFake.statusPaths]
685+
writeFileSync(statusPath, '0')
686+
687+
terminal.closeTerminal(activeTerminalId as string)
688+
689+
expect(createRunLedger(ledgerDir).list()).toEqual([])
690+
} finally {
691+
tmuxFake.on = false
692+
rmSync(scratch, { recursive: true, force: true })
693+
}
694+
})
695+
696+
it('never starts a tagged run it could not record', async () => {
697+
tmuxFake.on = true
698+
tmuxFake.statusPaths.clear()
699+
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
700+
// A file where the ledger's directory should be: no record can be saved.
701+
writeFileSync(join(scratch, 'terminal-runs'), '')
702+
try {
703+
const terminal = new TerminalService({
704+
loadCwd: () => '/tmp',
705+
runLedger: createRunLedger(join(scratch, 'terminal-runs')),
706+
})
707+
terminal.start({ cols: 80, rows: 24 })
708+
709+
await expect(
710+
terminal.executeTool('call-unrecorded', 'run', { command: 'make build', waitSeconds: 1 })
711+
).resolves.toMatchObject({ ok: false, code: 'SPAWN_FAILED' })
712+
expect(tmuxFake.statusPaths.size).toBe(0)
713+
} finally {
714+
tmuxFake.on = false
715+
rmSync(scratch, { recursive: true, force: true })
655716
}
656717
})
657718

0 commit comments

Comments
 (0)