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:
reaper
2026-08-08 05:52:52 -05:00
co-authored by Claude Sonnet 5
parent b5085c1e8a
commit 5beb70f1e2
2 changed files with 27 additions and 5 deletions
+27 -5
View File
@@ -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'))