Skip to content

Commit 38942a7

Browse files
authored
fix(desktop): run an agent command on tmux that cannot tag its pane, untracked (#8705)
* fix(desktop): run an agent command on tmux that cannot tag its pane, untracked Agent `run` inside tmux tagged its pane with a pane option (`set-option -p`, tmux 3.0+) and refused the command when that failed, so on older tmux every agent run failed, with the background executor off too. A run whose pane cannot be tagged now goes ahead untracked: Stop, sign-out and switching Terminal off leave it alone, since nothing could tell its pane from one of the user's. Tagged runs are unchanged. The gate still holds every command until the tagging call has finished. * fix(desktop): report an untracked run a Stop could not end as still running, and reap it once its pane is gone * fix(desktop): run untracked only on tmux without pane options, and reap and close finished untracked panes - Only tmux before 3.0, which refuses `set-option -p` as an unknown flag or invalid option, runs a command untracked. A tmux that can tag panes but did not (it timed out, or failed otherwise) gets the run refused, as before, so no command starts that Stop and sign-out could never end. - tmux 3.x answers `display-message` for a pane that is gone with an empty line rather than an error, so an untracked run's pane is gone unless tmux echoes its id back. - A finished untracked run's pane is closed, and a run that finishes after its call returned has its pane closed when it is reaped, so dead panes kept by `remain-on-exit` do not pile up. * fix(desktop): close a finished untracked run's pane only when its start command proves it is the run's * fix(desktop): recognise how real tmux before 3.0 refuses pane options, and use a separator no field can straddle - Real tmux 2.9a refuses `set-option -p` with its own getopt's `unknown option -- p` and set-option's usage line (BSD getopt on macOS: `illegal option -- p`). The untracked fallback only matched later wordings, so on real pre-3.0 tmux every run was refused. The fake tmux and the tests now use the text captured from real 2.9a. - The `-F` separator `|~sim~|` began and ended with the same character, so a field ending in `|~sim~` was misread rather than dropped. `<~sim~>` has no proper prefix that is also a suffix, so it is only found where it was written or wholly inside a field, whose line is then dropped.
1 parent 29038b3 commit 38942a7

5 files changed

Lines changed: 286 additions & 48 deletions

File tree

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,10 @@ export class TerminalService {
494494
}
495495
for (const handle of this.pendingRuns.get(terminalId) ?? []) {
496496
if (this.awaitedRuns.has(handle)) continue
497-
if (isRunComplete(handle) || (await runPaneState(handle, env)) === 'gone') {
497+
const complete = isRunComplete(handle)
498+
if (complete || (await runPaneState(handle, env)) === 'gone') {
499+
// A pane kept open after its command ended (`remain-on-exit`) closes with its run.
500+
if (complete) await closeRunPane(handle, env)
498501
this.untrackRun(terminalId, handle)
499502
handle.dispose()
500503
}
@@ -521,7 +524,8 @@ export class TerminalService {
521524
const pending = this.pendingRuns.get(terminalId)
522525
if (!pending) return
523526
for (const handle of pending) {
524-
if (env && !isRunComplete(handle)) this.orphanedRuns.set(handle, env)
527+
// An untracked run is never stopped, so there is nothing to keep it for.
528+
if (env && handle.runId !== null && !isRunComplete(handle)) this.orphanedRuns.set(handle, env)
525529
this.releaseRun(handle)
526530
}
527531
this.pendingRuns.delete(terminalId)
@@ -1300,7 +1304,11 @@ export class TerminalService {
13001304
if (latch.signal.aborted) void latch.stopRunning()
13011305
const outcome = await Promise.race([
13021306
awaitRun(handle, waitMs),
1303-
stopped.then(() => ({ ...pollRun(handle), done: true })),
1307+
// A stopped run's closed pane never writes its status. An untracked run is never stopped,
1308+
// so it is still going unless its status says otherwise.
1309+
stopped.then(() =>
1310+
handle.runId === null ? pollRun(handle) : { ...pollRun(handle), done: true }
1311+
),
13041312
]).finally(() => {
13051313
this.awaitedRuns.delete(handle)
13061314
if (this.releasedAwaitedRuns.delete(handle)) handle.dispose()

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const tmuxFake = vi.hoisted(() => ({
1616
stopped: [] as string[],
1717
/** Panes no longer the run's (closed by the user, or reused after a tmux restart). */
1818
gone: new Set<string>(),
19+
/** Runs start untracked, as on a tmux too old to tag their panes. */
20+
untracked: false,
21+
/** Run panes tmux still shows, kept open after their command ends (`remain-on-exit`). */
22+
open: new Set<string>(),
1923
statusPaths: new Map<string, string>(),
2024
}))
2125

@@ -37,28 +41,31 @@ vi.mock('@/main/terminal/tmux', async () => {
3741
const statusPath = join(dir, 'status')
3842
writeFileSync(join(dir, 'out'), 'partial output')
3943
tmuxFake.statusPaths.set(pane, statusPath)
44+
tmuxFake.open.add(pane)
4045
return {
4146
window: `@${pane.slice(1)}`,
4247
pane,
43-
runId: `run-${pane}`,
48+
runId: tmuxFake.untracked ? null : `run-${pane}`,
4449
outPath: join(dir, 'out'),
4550
statusPath,
4651
dispose: () => rmSync(dir, { recursive: true, force: true }),
4752
}
4853
},
4954
runPaneState: async (...args: Parameters<typeof actual.runPaneState>) => {
5055
if (!tmuxFake.on) return actual.runPaneState(...args)
51-
return tmuxFake.gone.has(args[0].pane) ? 'gone' : 'ours'
56+
if (tmuxFake.gone.has(args[0].pane)) return 'gone'
57+
return args[0].runId === null ? 'unknown' : 'ours'
5258
},
5359
stopRun: async (...args: Parameters<typeof actual.stopRun>) => {
5460
if (!tmuxFake.on) return actual.stopRun(...args)
5561
const [handle] = args
56-
if (tmuxFake.gone.has(handle.pane)) return
62+
if (tmuxFake.gone.has(handle.pane) || handle.runId === null) return
5763
tmuxFake.stopped.push(handle.pane)
5864
writeFileSync(handle.statusPath, '130')
5965
},
6066
closeRunPane: async (...args: Parameters<typeof actual.closeRunPane>) => {
6167
if (!tmuxFake.on) return actual.closeRunPane(...args)
68+
tmuxFake.open.delete(args[0].pane)
6269
},
6370
}
6471
})
@@ -587,6 +594,56 @@ describe('agent commands in tmux', () => {
587594
}
588595
})
589596

597+
it('reports an untracked run it could not stop as still running, and keeps tracking it', async () => {
598+
tmuxFake.on = true
599+
tmuxFake.untracked = true
600+
tmuxFake.statusPaths.clear()
601+
try {
602+
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
603+
terminal.start({ cols: 80, rows: 24 })
604+
const running = terminal.executeTool('call-untracked', 'run', {
605+
command: 'sleep 600',
606+
waitSeconds: 60,
607+
})
608+
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1))
609+
const [, statusPath = ''] = [...tmuxFake.statusPaths][0] ?? []
610+
611+
await terminal.cancelTool('call-untracked')
612+
613+
await expect(running).resolves.toMatchObject({ ok: true, result: { status: 'running' } })
614+
expect(existsSync(join(statusPath, '..'))).toBe(true)
615+
616+
// Once it does finish, the next run's bookkeeping reaps it.
617+
writeFileSync(statusPath, '0')
618+
await terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
619+
expect(existsSync(join(statusPath, '..'))).toBe(false)
620+
} finally {
621+
tmuxFake.on = false
622+
tmuxFake.untracked = false
623+
}
624+
})
625+
626+
it("closes a run's pane when a later run reaps it after it finished", async () => {
627+
tmuxFake.on = true
628+
tmuxFake.statusPaths.clear()
629+
tmuxFake.open.clear()
630+
try {
631+
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
632+
terminal.start({ cols: 80, rows: 24 })
633+
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
634+
const [[pane = '', statusPath = ''] = []] = [...tmuxFake.statusPaths]
635+
// It finishes after its call returned, and its dead pane stays open.
636+
writeFileSync(statusPath, '0')
637+
expect(tmuxFake.open.has(pane)).toBe(true)
638+
639+
await terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
640+
641+
expect(tmuxFake.open.has(pane)).toBe(false)
642+
} finally {
643+
tmuxFake.on = false
644+
}
645+
})
646+
590647
it("keeps a run's output readable for its call when the terminal goes away mid-wait", async () => {
591648
tmuxFake.on = true
592649
tmuxFake.statusPaths.clear()

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

Lines changed: 143 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { sleep } from '@sim/utils/helpers'
55
import { afterEach, describe, expect, it } from 'vitest'
66
import {
77
awaitRun,
8+
closeRunPane,
89
isDescendantOf,
910
parseFormatLines,
1011
pollRun,
@@ -15,13 +16,33 @@ import {
1516
type TmuxRunHandle,
1617
} from '@/main/terminal/tmux'
1718

19+
/** What real tmux 2.9a writes for `set-option -p`, captured from the binary. */
20+
const TMUX_29_NO_PANE_OPTIONS =
21+
'tmux: unknown option -- p\nusage: set-option [-aFgosquw] [-t target-window] option [value]\n'
22+
/** The same refusal from a tmux built against BSD getopt, as on macOS. */
23+
const TMUX_29_BSD_NO_PANE_OPTIONS =
24+
'tmux: illegal option -- p\nusage: set-option [-aFgosquw] [-t target-window] option [value]\n'
25+
1826
/** The separator the format strings use. */
19-
const F = '|~sim~|'
27+
const F = '<~sim~>'
2028

2129
describe('parseFormatLines', () => {
2230
it('drops lines with the wrong field count rather than mis-assigning them', () => {
2331
expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']])
2432
})
33+
34+
it('reads a field that ends with part of the separator as it is', () => {
35+
// A cwd or window name may end with any text, including all but the separator's last character.
36+
const partial = F.slice(0, -1)
37+
expect(parseFormatLines(`/tmp/x/p${partial}${F}1\n`, 2)).toEqual([[`/tmp/x/p${partial}`, '1']])
38+
expect(parseFormatLines(`tail${partial}${F}%3${F}zsh\n`, 3)).toEqual([
39+
[`tail${partial}`, '%3', 'zsh'],
40+
])
41+
})
42+
43+
it('drops a line whose field holds the whole separator rather than misread it', () => {
44+
expect(parseFormatLines(`a${F}b${F}c\n`, 2)).toEqual([])
45+
})
2546
})
2647

2748
describe('isDescendantOf', () => {
@@ -100,13 +121,17 @@ describe('run status files', () => {
100121
interface FakeTmuxState {
101122
nextWindow: number
102123
nextPane: number
103-
panes: Record<string, { window: string; options: Record<string, string> }>
124+
panes: Record<string, { window: string; options: Record<string, string>; command?: string }>
104125
/** Every command that reached a pane: `send-keys %1 C-c`, `kill-pane %1`. */
105126
log: string[]
106127
/** Commands the fake fails, with the error tmux would print. */
107128
fail?: Record<string, string>
108129
/** Attached clients, as `list-clients` reports them. */
109130
clients?: Array<{ pid: string; tty: string; session: string }>
131+
/** Commands the fake holds until the file named here exists, like a busy tmux server. */
132+
hold?: Record<string, string>
133+
/** Commands the fake is holding right now. */
134+
held?: string[]
110135
}
111136

112137
const FAKE_TMUX = `
@@ -123,12 +148,20 @@ const escaped = (text) =>
123148
text
124149
.replace(/\\\\/g, '\\\\\\\\')
125150
.replace(/[\\x00-\\x1f]/g, (c) => '\\\\' + c.charCodeAt(0).toString(8).padStart(3, '0'))
151+
if (state.hold && state.hold[args[0]]) {
152+
state.held = [...(state.held ?? []), args[0]]
153+
save()
154+
while (!fs.existsSync(state.hold[args[0]])) {
155+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20)
156+
}
157+
state.held = state.held.filter((command) => command !== args[0])
158+
}
126159
if (state.fail && state.fail[args[0]]) fail(state.fail[args[0]])
127160
switch (args[0]) {
128161
case 'new-window': {
129162
const window = '@' + state.nextWindow++
130163
const pane = '%' + state.nextPane++
131-
state.panes[pane] = { window, options: {} }
164+
state.panes[pane] = { window, options: {}, command: args[args.length - 1] }
132165
save()
133166
// Runs the pane's command for real, the way tmux would, when a test asks for it.
134167
if (process.env.FAKE_TMUX_EXEC) {
@@ -147,10 +180,17 @@ switch (args[0]) {
147180
break
148181
}
149182
case 'display-message': {
183+
// Like tmux 3.x, a pane that is gone answers with an empty line rather than an error.
150184
const pane = state.panes[target()]
151-
if (!pane) fail("can't find pane")
152185
const name = args[args.length - 1].slice(2, -1)
153-
process.stdout.write((pane.options[name] ?? '') + '\\n')
186+
const value = !pane
187+
? ''
188+
: name === 'pane_id'
189+
? target()
190+
: name === 'pane_start_command'
191+
? (pane.command ?? '')
192+
: (pane.options[name] ?? '')
193+
process.stdout.write(value + '\\n')
154194
break
155195
}
156196
case 'list-clients': {
@@ -287,16 +327,84 @@ describe('stopping a tmux run touches only its own pane', () => {
287327
expect(tmux.read().log).toEqual([])
288328
})
289329

290-
it('never lets a run it could not tag start, and closes no pane by id to stop it', async () => {
330+
it.each([
331+
['tmux 2.9a', TMUX_29_NO_PANE_OPTIONS],
332+
['BSD getopt', TMUX_29_BSD_NO_PANE_OPTIONS],
333+
])(
334+
'starts a run untracked on a tmux without pane options (%s), and never stops it by a pane id',
335+
async (_build, refusal) => {
336+
const tmux = fakeTmux()
337+
dirs.push(tmux.dir)
338+
// tmux before 3.0 has no pane options.
339+
tmux.write({ ...tmux.read(), fail: { 'set-option': refusal } })
340+
341+
const run = await startRun('agent', 'sleep 600', null, tmux.env)
342+
if ('error' in run) throw new Error(run.error)
343+
344+
expect(run.runId).toBeNull()
345+
expect(existsSync(join(run.statusPath, '..', 'go'))).toBe(true)
346+
expect(await runPaneState(run, tmux.env)).toBe('unknown')
347+
await stopRun(run, tmux.env, 0)
348+
// No pane is touched by an id that a restarted server might have handed to the user.
349+
expect(tmux.read().log).toEqual([])
350+
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
351+
352+
// Once its pane is gone, it can be let go.
353+
const state = tmux.read()
354+
delete state.panes[run.pane]
355+
tmux.write(state)
356+
expect(await runPaneState(run, tmux.env)).toBe('gone')
357+
}
358+
)
359+
360+
it.each(['tmux did not respond', 'server exited unexpectedly'])(
361+
'refuses a run that a tmux able to tag panes did not tag (%s)',
362+
async (failure) => {
363+
const tmux = fakeTmux()
364+
dirs.push(tmux.dir)
365+
tmux.write({ ...tmux.read(), fail: { 'set-option': failure } })
366+
367+
const result = await startRun('agent', 'sleep 600', null, tmux.env)
368+
369+
// Untagged on a tmux that tags, nothing could stop it later, so it never starts.
370+
expect(result).toMatchObject({ error: expect.stringContaining('was not run') })
371+
expect(tmux.read().log).toEqual([])
372+
}
373+
)
374+
375+
it("closes a finished untracked run's pane, and only once it has finished", async () => {
291376
const tmux = fakeTmux()
292377
dirs.push(tmux.dir)
293-
tmux.write({ ...tmux.read(), fail: { 'set-option': 'invalid option: @sim-run-id' } })
378+
tmux.write({ ...tmux.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
379+
const run = await startRun('agent', 'make build', null, tmux.env)
380+
if ('error' in run) throw new Error(run.error)
294381

295-
const result = await startRun('agent', 'sleep 600', null, tmux.env)
382+
await closeRunPane(run, tmux.env)
383+
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
384+
385+
// Its command ended; with `remain-on-exit` its dead pane would otherwise stay open.
386+
writeFileSync(run.statusPath, '0')
387+
await closeRunPane(run, tmux.env)
388+
expect(Object.keys(tmux.read().panes)).toEqual([])
389+
})
390+
391+
it("never closes a pane that took a finished untracked run's id after tmux restarted", async () => {
392+
const tmux = fakeTmux()
393+
dirs.push(tmux.dir)
394+
tmux.write({ ...tmux.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
395+
const run = await startRun('agent', 'make build', null, tmux.env)
396+
if ('error' in run) throw new Error(run.error)
397+
writeFileSync(run.statusPath, '0')
398+
tmux.restart()
399+
// The user's own shell gets the ids the run's pane had.
400+
const state = tmux.read()
401+
state.panes[run.pane] = { window: run.window, options: {}, command: 'zsh' }
402+
tmux.write(state)
403+
404+
await closeRunPane(run, tmux.env)
296405

297-
expect(result).toMatchObject({ error: expect.stringContaining('was not run') })
298-
// No pane is closed by an id that a restarted server might have handed to the user.
299406
expect(tmux.read().log).toEqual([])
407+
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
300408
})
301409

302410
it('lets a tagged run start only once its pane is tagged', async () => {
@@ -318,7 +426,7 @@ describe('stopping a tmux run touches only its own pane', () => {
318426
expect(tmux.read().log).toEqual([])
319427
})
320428

321-
it('runs a tagged command for real, and never runs one it could not tag', async () => {
429+
it('runs a command for real whether or not tmux could tag its pane', async () => {
322430
const tagged = fakeTmux({ exec: true })
323431
dirs.push(tagged.dir)
324432
const run = await startRun('agent', 'echo ran', null, tagged.env)
@@ -332,11 +440,31 @@ describe('stopping a tmux run touches only its own pane', () => {
332440

333441
const untagged = fakeTmux({ exec: true })
334442
dirs.push(untagged.dir)
335-
untagged.write({ ...untagged.read(), fail: { 'set-option': 'invalid option' } })
336-
const marker = join(untagged.dir, 'ran')
337-
await startRun('agent', `touch ${JSON.stringify(marker)}`, null, untagged.env)
338-
await sleep(6_000)
443+
untagged.write({ ...untagged.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
444+
const untracked = await startRun('agent', 'echo ran', null, untagged.env)
445+
if ('error' in untracked) throw new Error(untracked.error)
446+
await expect
447+
.poll(() => pollRun(untracked), { timeout: 10_000 })
448+
.toMatchObject({ done: true, exitCode: 0, output: 'ran\n' })
449+
}, 20_000)
339450

451+
it('holds a command until tmux has finished tagging its pane', async () => {
452+
const tmux = fakeTmux({ exec: true })
453+
dirs.push(tmux.dir)
454+
const release = join(tmux.dir, 'release')
455+
tmux.write({ ...tmux.read(), hold: { 'set-option': release } })
456+
const marker = join(tmux.dir, 'ran')
457+
458+
const starting = startRun('agent', `touch ${JSON.stringify(marker)}`, null, tmux.env)
459+
// The pane is open and the tagging call is in flight, held by tmux.
460+
await expect.poll(() => tmux.read().held ?? [], { timeout: 10_000 }).toEqual(['set-option'])
461+
// Time enough for an ungated command to have run.
462+
await sleep(1_000)
340463
expect(existsSync(marker)).toBe(false)
464+
465+
writeFileSync(release, '')
466+
const run = await starting
467+
if ('error' in run) throw new Error(run.error)
468+
await expect.poll(() => existsSync(marker), { timeout: 10_000 }).toBe(true)
341469
}, 20_000)
342470
})

0 commit comments

Comments
 (0)