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 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 {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -37,7 +50,16 @@ export function runStreamed(
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<void> {
|
||||
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)
|
||||
|
||||
const tail: string[] = []
|
||||
@@ -47,7 +69,7 @@ export function runStreamed(
|
||||
}
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString()
|
||||
const chunk = stripAnsi(data.toString())
|
||||
trackTail(chunk)
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
|
||||
@@ -55,7 +77,7 @@ export function runStreamed(
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString()
|
||||
const chunk = stripAnsi(data.toString())
|
||||
trackTail(chunk)
|
||||
if (!sender.isDestroyed()) {
|
||||
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
|
||||
// dedicated logs:data channel instead of terminal:data.
|
||||
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)
|
||||
|
||||
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
||||
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'))
|
||||
|
||||
Reference in New Issue
Block a user