fix(pty): preserve persistent bash exit status

This commit is contained in:
Tianyi Cui
2026-07-29 21:24:33 +08:00
parent b9bc1c8a61
commit f8df313f21
2 changed files with 46 additions and 7 deletions

View File

@@ -43,6 +43,7 @@ interface RetainedOutput {
interface CapturedOutput { interface CapturedOutput {
text: string text: string
incomplete: boolean incomplete: boolean
exitCode?: number
} }
interface PersistentShells { interface PersistentShells {
@@ -94,11 +95,13 @@ function commandOutput(
): CapturedOutput { ): CapturedOutput {
const text = snapshot.text const text = snapshot.text
const end = text.lastIndexOf(marker.end) const end = text.lastIndexOf(marker.end)
const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10)
const startMarker = text.lastIndexOf(marker.start, end) const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return { return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0, incomplete: startMarker < 0,
exitCode,
} }
} }
@@ -165,9 +168,24 @@ function retainedScrollback(
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
return output.incomplete && output.text.length > 0 const withPrefix = output.incomplete && output.text.length > 0
? LOST_PREFIX_MESSAGE + rendered ? LOST_PREFIX_MESSAGE + rendered
: rendered : rendered
return renderExitStatus(withPrefix, output.exitCode ?? 0, null)
}
function renderExitStatus(
content: string,
exitCode: number | null,
signal: NodeJS.Signals | null,
): string {
const marker = signal !== null
? `[killed by signal: ${signal}]`
: exitCode !== null && exitCode !== 0
? `[exit code: ${exitCode}]`
: undefined
if (marker === undefined) return content
return content.length === 0 ? marker : `${content}\n${marker}`
} }
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
@@ -299,7 +317,11 @@ async function executeCommand(
const snapshot = retainedScrollback(ctx, owner, id, latest) const snapshot = retainedScrollback(ctx, owner, id, latest)
await shells.reset(owner, 'persistent bash shell exited') await shells.reset(owner, 'persistent bash shell exited')
return [ return [
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), renderExitStatus(
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
result.sessionStatus.exitCode,
result.sessionStatus.signal,
),
SHELL_RESET_MESSAGE, SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n') ].filter(part => part.length > 0).join('\n')
} }

View File

@@ -78,9 +78,11 @@ type StubMode =
| 'empty-read' | 'empty-read'
| 'stalled-read' | 'stalled-read'
| 'exit' | 'exit'
| 'signal-exit'
| 'wait-for-abort' | 'wait-for-abort'
| 'idle-then-normal' | 'idle-then-normal'
| 'large' | 'large'
| 'nonzero'
| 'end-only' | 'end-only'
| 'init-exit' | 'init-exit'
| 'init-timeout' | 'init-timeout'
@@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession {
this.scrollback += output this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
} }
const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' const commandOutput = this.mode === 'large'
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` ? 'x'.repeat(100)
: this.mode === 'nonzero' ? '' : 'hello from stub'
const exitCode = this.mode === 'nonzero' ? 7 : 0
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
this.scrollback += output this.scrollback += output
if (this.mode === 'exit') { if (this.mode === 'exit' || this.mode === 'signal-exit') {
const exitedOutput = `${start ?? ''}\nhello from stub\n` const exitedOutput = `${start ?? ''}\nhello from stub\n`
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
this.statusValue = { kind: 'exited', exitCode: 0, signal: null } this.statusValue = this.mode === 'signal-exit'
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
: { kind: 'exited', exitCode: 9, signal: null }
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
} }
return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
@@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => {
session.mode = 'large' session.mode = 'large'
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>') expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
session.mode = 'nonzero'
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
session.mode = 'exit' session.mode = 'exit'
const exited = text(await call(ctx, owner, 'exit')) const exited = text(await call(ctx, owner, 'exit'))
expect(exited).toContain('hello from') expect(exited).toContain('hello from')
expect(exited).toContain('[exit code: 9]')
expect(exited).toContain('next bash call starts from the workspace') expect(exited).toContain('next bash call starts from the workspace')
expect(session.closed).toContain('persistent bash shell exited') expect(session.closed).toContain('persistent bash shell exited')
await call(ctx, owner, 'new shell') await call(ctx, owner, 'new shell')
expect(stub.sessions).toHaveLength(2) expect(stub.sessions).toHaveLength(2)
const replacement = stub.sessions[1]!
replacement.mode = 'signal-exit'
expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]')
await call(ctx, owner, 'another shell')
expect(stub.sessions).toHaveLength(3)
const externallyClosed = ctx.pty.list(owner)[0]?.sessionId const externallyClosed = ctx.pty.list(owner)[0]?.sessionId
expect(externallyClosed).toBeDefined() expect(externallyClosed).toBeDefined()
await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') await ctx.pty.kill(owner, externallyClosed!, 'external cleanup')
await fiber.dispose() await fiber.dispose()
expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
}) })
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {