fix(runtime): close async cleanup and framing races

This commit is contained in:
Tianyi Cui
2026-07-29 01:18:01 +08:00
parent 16cac3b5af
commit ac81165662
15 changed files with 109 additions and 47 deletions

View File

@@ -14,6 +14,7 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { LocalPtySession } from './session.ts'
import { CONTROLLED_PROMPT } from './sanitize.ts'
export { Config } from './config.ts'
export type { Config as PtyLocalConfig } from './config.ts'
@@ -58,7 +59,7 @@ function childEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PS1: CONTROLLED_PROMPT,
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',

View File

@@ -5,12 +5,15 @@ import { Buffer } from 'node:buffer'
/** OSC marker emitted by the controlled bash before each prompt. */
export const PROMPT_MARKER_PREFIX = '133;D;'
/** Exact printable prompt emitted after the private marker. */
export const CONTROLLED_PROMPT = 'dsh> '
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
/** Present when printable text followed the latest owned prompt marker. */
promptText?: true
/** Printable text after the latest owned marker in this chunk. */
promptTail?: string
}
/**
@@ -23,7 +26,7 @@ export class TerminalSanitizer {
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private awaitingPromptText = false
private trackingPromptTail = false
constructor(private readonly maxPendingBytes: number) {}
@@ -36,24 +39,21 @@ export class TerminalSanitizer {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let promptText = false
let includePromptTail = this.trackingPromptTail
let promptTail = ''
let index = 0
const appendText = (value: string): boolean => {
const appendText = (value: string): void => {
text += value
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
this.awaitingPromptText = false
return true
}
return false
if (this.trackingPromptTail) promptTail += value
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
promptText = appendText(this.pending.slice(index)) || promptText
appendText(this.pending.slice(index))
index = this.pending.length
break
}
promptText = appendText(this.pending.slice(index, escape)) || promptText
appendText(this.pending.slice(index, escape))
if (escape + 1 >= this.pending.length) {
index = escape
break
@@ -74,8 +74,9 @@ export class TerminalSanitizer {
const content = this.pending.slice(escape + 2, end - terminatorBytes)
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
prompt = true
promptText = false
this.awaitingPromptText = true
this.trackingPromptTail = true
includePromptTail = true
promptTail = ''
}
index = end
continue
@@ -99,7 +100,11 @@ export class TerminalSanitizer {
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
return {
text: this.normalizeText(text),
prompt,
...includePromptTail ? { promptTail } : {},
}
}
/**
@@ -111,7 +116,7 @@ export class TerminalSanitizer {
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
this.awaitingPromptText = false
this.trackingPromptTail = false
const normalized = this.normalizeText(text)
if (!this.trailingCarriageReturn) return normalized
this.trailingCarriageReturn = false

View File

@@ -20,7 +20,7 @@ import type {
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import { TerminalSanitizer } from './sanitize.ts'
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
@@ -77,6 +77,7 @@ class LocalSendOperation implements PtySendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<PtySendResult>
private finished = false
private cancellationRequested = false
private initialForegroundLeftWait: boolean
private initialForegroundPgid: number | undefined
@@ -98,6 +99,10 @@ class LocalSendOperation implements PtySendOperation {
return this.finished
}
get cancelRequested(): boolean {
return this.cancellationRequested
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
@@ -140,6 +145,7 @@ class LocalSendOperation implements PtySendOperation {
cancel(): boolean {
if (this.finished) return false
this.cancellationRequested = true
this.onCancel()
return true
}
@@ -164,6 +170,7 @@ export class LocalPtySession implements PtyBackendSession {
private polling = false
private promptSeen = false
private promptTextSeen = false
private promptTail = ''
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
@@ -223,6 +230,7 @@ export class LocalPtySession implements PtyBackendSession {
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.promptTail = ''
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
@@ -242,7 +250,7 @@ export class LocalPtySession implements PtyBackendSession {
if (this.active !== operation || this.closing) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0) {
if (input.length > 0 && !operation.cancelRequested) {
this.writing = operation
try {
await this.terminal.write(Buffer.from(input, 'utf8'))
@@ -347,10 +355,14 @@ export class LocalPtySession implements PtyBackendSession {
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.promptTail = ''
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
if (this.promptSeen && sanitized.promptTail !== undefined) {
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
this.promptTail += sanitized.promptTail.slice(0, remaining)
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
}
}