fix(runtime): close async cleanup and framing races
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user