Просмотр исходного кода

fix(pty): preserve persistent bash exit status

Tianyi Cui 1 месяц назад
Родитель
Сommit
f8df313f21

+ 24 - 2
packages/pty/tool-bash-persistent/src/index.ts

@@ -43,6 +43,7 @@ interface RetainedOutput {
 interface CapturedOutput {
   text: string
   incomplete: boolean
+  exitCode?: number
 }
 
 interface PersistentShells {
@@ -94,11 +95,13 @@ function commandOutput(
 ): CapturedOutput {
   const text = snapshot.text
   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 start = startMarker < 0 ? 0 : startMarker + marker.start.length
   return {
     text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
     incomplete: startMarker < 0,
+    exitCode,
   }
 }
 
@@ -165,9 +168,24 @@ function retainedScrollback(
 
 function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
   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
     : 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 {
@@ -299,7 +317,11 @@ async function executeCommand(
       const snapshot = retainedScrollback(ctx, owner, id, latest)
       await shells.reset(owner, 'persistent bash shell exited')
       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,
       ].filter(part => part.length > 0).join('\n')
     }

+ 22 - 5
packages/pty/tool-bash-persistent/tests/tools.spec.ts

@@ -78,9 +78,11 @@ type StubMode =
   | 'empty-read'
   | 'stalled-read'
   | 'exit'
+  | 'signal-exit'
   | 'wait-for-abort'
   | 'idle-then-normal'
   | 'large'
+  | 'nonzero'
   | 'end-only'
   | 'init-exit'
   | 'init-timeout'
@@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession {
       this.scrollback += output
       return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
     }
-    const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub'
-    const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}`
+    const commandOutput = this.mode === 'large'
+      ? '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
-    if (this.mode === 'exit') {
+    if (this.mode === 'exit' || this.mode === 'signal-exit') {
       const exitedOutput = `${start ?? ''}\nhello from stub\n`
       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(output, 'stdin_read')))
@@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => {
     session.mode = 'large'
     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'
     const exited = text(await call(ctx, owner, 'exit'))
     expect(exited).toContain('hello from')
+    expect(exited).toContain('[exit code: 9]')
     expect(exited).toContain('next bash call starts from the workspace')
     expect(session.closed).toContain('persistent bash shell exited')
 
     await call(ctx, owner, 'new shell')
     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
     expect(externallyClosed).toBeDefined()
     await ctx.pty.kill(owner, externallyClosed!, 'external cleanup')
     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 () => {