Explorar o código

fix(pty): preserve persistent bash exit status

Tianyi Cui hai 1 mes
pai
achega
f8df313f21

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

@@ -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')
     }
     }

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

@@ -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 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
     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 () => {