Fix streamed ddev commands hanging forever on interactive prompts
Node's spawn() leaves a child's stdin as an open-but-silent pipe by default. A GUI-spawned ddev process has no terminal to answer a prompt (e.g. the first-run telemetry opt-in), and a blocking read on an open pipe with no EOF just hangs indefinitely instead of failing or defaulting — reproduced by a "Start" operation that never progressed past "Permission to beam up?" with no way to recover short of force-quitting the app. Closing stdin gives ddev an immediate EOF, which it treats as "use the default" rather than blocking. Also strips ANSI escape codes from stdout/stderr before forwarding to the renderer — ddev colorizes output unconditionally regardless of TTY-ness, so the terminal panel and log viewer were rendering literal escape sequences as visible text. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b5085c1e8a
commit
5beb70f1e2
@@ -10,6 +10,19 @@ const ENV_WITH_DDEV_PATH = {
|
|||||||
const running = new Map<string, ChildProcess>()
|
const running = new Map<string, ChildProcess>()
|
||||||
const cancelledIds = new Set<string>()
|
const cancelledIds = new Set<string>()
|
||||||
|
|
||||||
|
// ddev colorizes its output unconditionally, regardless of TTY-ness, so a
|
||||||
|
// GUI panel rendering raw stdout/stderr ends up showing literal escape
|
||||||
|
// codes instead of color. Strip them before they ever reach the renderer.
|
||||||
|
const ANSI_PATTERN = new RegExp(
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: matches ESC/BEL bytes in ddev's colorized output
|
||||||
|
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
|
||||||
|
function stripAnsi(text: string): string {
|
||||||
|
return text.replace(ANSI_PATTERN, '')
|
||||||
|
}
|
||||||
|
|
||||||
export class CommandFailedError extends Error {
|
export class CommandFailedError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -37,7 +50,16 @@ export function runStreamed(
|
|||||||
options: { cwd?: string } = {}
|
options: { cwd?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, cwd: options.cwd })
|
// stdin must be closed, not just unused — an open-but-silent pipe
|
||||||
|
// leaves ddev blocked forever on any prompt it tries to read (e.g. the
|
||||||
|
// first-run telemetry opt-in), since a GUI-spawned child has no
|
||||||
|
// terminal to answer it. A closed stdin gets an immediate EOF instead,
|
||||||
|
// which ddev treats as "use the default" rather than hanging.
|
||||||
|
const child = spawn('ddev', args, {
|
||||||
|
env: ENV_WITH_DDEV_PATH,
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
running.set(operationId, child)
|
running.set(operationId, child)
|
||||||
|
|
||||||
const tail: string[] = []
|
const tail: string[] = []
|
||||||
@@ -47,7 +69,7 @@ export function runStreamed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
child.stdout.on('data', (data: Buffer) => {
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
const chunk = data.toString()
|
const chunk = stripAnsi(data.toString())
|
||||||
trackTail(chunk)
|
trackTail(chunk)
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
|
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
|
||||||
@@ -55,7 +77,7 @@ export function runStreamed(
|
|||||||
})
|
})
|
||||||
|
|
||||||
child.stderr.on('data', (data: Buffer) => {
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
const chunk = data.toString()
|
const chunk = stripAnsi(data.toString())
|
||||||
trackTail(chunk)
|
trackTail(chunk)
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
|
sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
|
||||||
@@ -105,12 +127,12 @@ export function cancelCommand(operationId: string): boolean {
|
|||||||
// stopping a log stream reuses cancelCommand — but pushes chunks over a
|
// stopping a log stream reuses cancelCommand — but pushes chunks over a
|
||||||
// dedicated logs:data channel instead of terminal:data.
|
// dedicated logs:data channel instead of terminal:data.
|
||||||
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
|
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
|
||||||
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH })
|
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
running.set(operationId, child)
|
running.set(operationId, child)
|
||||||
|
|
||||||
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
||||||
if (!sender.isDestroyed()) {
|
if (!sender.isDestroyed()) {
|
||||||
sender.send('logs:data', { operationId, stream, chunk: data.toString() })
|
sender.send('logs:data', { operationId, stream, chunk: stripAnsi(data.toString()) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
child.stdout.on('data', forward('stdout'))
|
child.stdout.on('data', forward('stdout'))
|
||||||
|
|||||||
Reference in New Issue
Block a user