diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 8477287ff4..ec359e9922 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -6,6 +6,7 @@ import {render} from '@shopify/cli-kit/node/ui' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {isTruthy} from '@shopify/cli-kit/node/context/utilities' import {isUnitTest} from '@shopify/cli-kit/node/context/local' +import {outputDebug} from '@shopify/cli-kit/node/output' export async function renderDev({ processes, @@ -28,9 +29,19 @@ export async function renderDev({ organizationName?: string configPath?: string }) { - if (!terminalSupportsPrompting()) { + const supportsPrompting = terminalSupportsPrompting() + const supportsDevSessions = app.developerPlatformClient.supportsDevSessions + + outputDebug(`[renderDev] Terminal supports prompting: ${supportsPrompting}`) + outputDebug(`[renderDev] stdin.isTTY: ${process.stdin.isTTY}, stdout.isTTY: ${process.stdout.isTTY}`) + outputDebug(`[renderDev] Developer platform supports dev sessions: ${supportsDevSessions}`) + outputDebug(`[renderDev] Number of processes: ${processes.length}`) + + if (!supportsPrompting) { + outputDebug(`[renderDev] Using NON-INTERACTIVE mode (piping to process.stdout/stderr directly)`) await renderDevNonInteractive({processes, app, abortController, developerPreview, shopFqdn}) - } else if (app.developerPlatformClient.supportsDevSessions) { + } else if (supportsDevSessions) { + outputDebug(`[renderDev] Using DevSessionUI (interactive with dev sessions)`) return render( { expect(renderInstance.waitUntilExit().isFulfilled()).toBe(false) }) + + test('handles delayed/buffered writes correctly (simulates Ubuntu 24.04 issue #6726)', async () => { + // This test simulates the scenario where child process output may be + // delayed or buffered differently on certain Linux distributions. + // The issue manifests as hot reload working but terminal output being silent. + + const processSync = new Synchronizer() + const receivedOutput: string[] = [] + + const delayedProcess = { + prefix: 'web', + action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { + // Simulate delayed writes like a real dev server would produce + stdout.write('Starting server...\n') + + // Small delay to simulate async server startup + await new Promise((resolve) => setTimeout(resolve, 10)) + stdout.write('Server listening on port 3000\n') + + // Another delay to simulate file change detection + await new Promise((resolve) => setTimeout(resolve, 10)) + stdout.write('File changed: index.tsx\n') + stdout.write('Rebuilding...\n') + + await new Promise((resolve) => setTimeout(resolve, 10)) + stdout.write('Build complete\n') + + processSync.resolve() + }, + } + + // When + const renderInstance = render( + , + ) + + await processSync.promise + // Give time for all writes to be processed + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Then - verify all messages were captured + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('Starting server...') + expect(output).toContain('Server listening on port 3000') + expect(output).toContain('File changed: index.tsx') + expect(output).toContain('Rebuilding...') + expect(output).toContain('Build complete') + }) + + test('handles rapid consecutive writes without dropping output', async () => { + // Tests for potential race conditions in output handling + const processSync = new Synchronizer() + const messageCount = 100 + + const rapidWriteProcess = { + prefix: 'rapid', + action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { + // Rapidly write many messages without any delay + for (let i = 0; i < messageCount; i++) { + stdout.write(`message ${i}\n`) + } + processSync.resolve() + }, + } + + // When + const renderInstance = render( + , + ) + + await processSync.promise + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then - verify all messages were captured + const output = unstyled(renderInstance.lastFrame()!) + const lines = output.split('\n').filter((line) => line.includes('message')) + expect(lines.length).toBe(messageCount) + }) + + test('handles stderr output alongside stdout', async () => { + const processSync = new Synchronizer() + + const mixedOutputProcess = { + prefix: 'mixed', + action: async (stdout: Writable, stderr: Writable, _signal: AbortSignal) => { + stdout.write('stdout: normal output\n') + stderr.write('stderr: error output\n') + stdout.write('stdout: more output\n') + stderr.write('stderr: warning\n') + processSync.resolve() + }, + } + + // When + const renderInstance = render( + , + ) + + await processSync.promise + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Then - both stdout and stderr should be captured + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('stdout: normal output') + expect(output).toContain('stderr: error output') + expect(output).toContain('stdout: more output') + expect(output).toContain('stderr: warning') + }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx index da83738b86..c9f75782d2 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -1,4 +1,4 @@ -import {OutputProcess} from '../../../../public/node/output.js' +import {OutputProcess, outputDebug} from '../../../../public/node/output.js' import {AbortSignal} from '../../../../public/node/abort.js' import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react' import {Box, Static, Text, TextProps, useApp} from 'ink' @@ -132,24 +132,40 @@ const ConcurrentOutput: FunctionComponent = ({ const writableStream = useCallback( (process: OutputProcess, prefixes: string[]) => { return new Writable({ - write(chunk, _encoding, next) { - const context = outputContextStore.getStore() - const prefix = context?.outputPrefix ?? process.prefix - const shouldStripAnsi = context?.stripAnsi ?? true - const log = chunk.toString('utf8').replace(/(\n)$/, '') - - const index = addPrefix(prefix, prefixes) - - const lines = shouldStripAnsi ? stripAnsi(log).split(/\n/) : log.split(/\n/) - setProcessOutput((previousProcessOutput) => [ - ...previousProcessOutput, - { - color: lineColor(index), - prefix, - lines, - }, - ]) - next() + // Explicitly set options for cross-platform compatibility + // This addresses potential buffering issues on Ubuntu 24.04 (#6726) + decodeStrings: false, + defaultEncoding: 'utf8', + // Use a smaller high water mark to ensure data flows through quickly + highWaterMark: 16, + write(chunk, encoding, next) { + try { + const context = outputContextStore.getStore() + const prefix = context?.outputPrefix ?? process.prefix + const shouldStripAnsi = context?.stripAnsi ?? true + // Handle both Buffer and string chunks + const log = (typeof chunk === 'string' ? chunk : chunk.toString('utf8')).replace(/(\n)$/, '') + + outputDebug(`[ConcurrentOutput] Received chunk for prefix "${prefix}": ${log.substring(0, 100)}${log.length > 100 ? '...' : ''}`) + + const index = addPrefix(prefix, prefixes) + + const lines = shouldStripAnsi ? stripAnsi(log).split(/\n/) : log.split(/\n/) + outputDebug(`[ConcurrentOutput] Processing ${lines.length} line(s) for prefix "${prefix}"`) + + setProcessOutput((previousProcessOutput) => [ + ...previousProcessOutput, + { + color: lineColor(index), + prefix, + lines, + }, + ]) + next() + } catch (error) { + outputDebug(`[ConcurrentOutput] Error processing chunk: ${error}`) + next(error as Error) + } }, }) }, diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index b906417431..598f1ecc10 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -78,10 +78,20 @@ export async function exec(command: string, args: string[], options?: ExecOption } if (options?.stderr && options.stderr !== 'inherit') { + outputDebug(`[exec] Piping stderr for command: ${command}`) commandProcess.stderr?.pipe(options.stderr, {end: false}) + // Add debug listener to track data flow + commandProcess.stderr?.on('data', (chunk) => { + outputDebug(`[exec] stderr data received (${chunk.length} bytes) for: ${command}`) + }) } if (options?.stdout && options.stdout !== 'inherit') { + outputDebug(`[exec] Piping stdout for command: ${command}`) commandProcess.stdout?.pipe(options.stdout, {end: false}) + // Add debug listener to track data flow + commandProcess.stdout?.on('data', (chunk) => { + outputDebug(`[exec] stdout data received (${chunk.length} bytes) for: ${command}`) + }) } let aborted = false options?.signal?.addEventListener('abort', () => { @@ -137,6 +147,9 @@ function buildExec(command: string, args: string[], options?: ExecOptions): Exec windowsHide: false, detached: options?.background, cleanup: !options?.background, + // Disable buffering for stdout/stderr to ensure real-time streaming + // This helps address output swallowing issues on Ubuntu 24.04 (#6726) + buffer: false, }) outputDebug(`Running system process${options?.background ? ' in background' : ''}: ยท Command: ${command} ${args.join(' ')}