Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/app/src/cli/services/dev/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
<DevSessionUI
processes={processes}
Expand All @@ -50,6 +61,7 @@ export async function renderDev({
},
)
} else {
outputDebug(`[renderDev] Using Dev component (interactive without dev sessions)`)
return render(
<Dev
processes={processes}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,124 @@ describe('ConcurrentOutput', () => {

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(
<ConcurrentOutput
processes={[delayedProcess]}
abortSignal={new AbortController().signal}
keepRunningAfterProcessesResolve
/>,
)

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(
<ConcurrentOutput
processes={[rapidWriteProcess]}
abortSignal={new AbortController().signal}
keepRunningAfterProcessesResolve
/>,
)

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(
<ConcurrentOutput
processes={[mixedOutputProcess]}
abortSignal={new AbortController().signal}
keepRunningAfterProcessesResolve
/>,
)

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')
})
})
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -132,24 +132,40 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
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)
}
},
})
},
Expand Down
121 changes: 120 additions & 1 deletion packages/cli-kit/src/public/node/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as system from './system.js'
import {execa} from 'execa'
import {describe, expect, test, vi} from 'vitest'
import {afterEach, describe, expect, test, vi} from 'vitest'
import which from 'which'
import {Readable} from 'stream'
import * as fs from 'fs'
Expand Down Expand Up @@ -113,3 +113,122 @@ describe('readStdinString', () => {
expect(got).toBe('hello world')
})
})

describe('getWslDistroName', () => {
test('returns the WSL_DISTRO_NAME environment variable when set', () => {
// Given
const originalEnv = process.env.WSL_DISTRO_NAME
process.env.WSL_DISTRO_NAME = 'Ubuntu'

// When
const got = system.getWslDistroName()

// Then
expect(got).toBe('Ubuntu')

// Cleanup
process.env.WSL_DISTRO_NAME = originalEnv
})

test('returns undefined when WSL_DISTRO_NAME is not set', () => {
// Given
const originalEnv = process.env.WSL_DISTRO_NAME
delete process.env.WSL_DISTRO_NAME

// When
const got = system.getWslDistroName()

// Then
expect(got).toBeUndefined()

// Cleanup
process.env.WSL_DISTRO_NAME = originalEnv
})
})

describe('convertToWslFileUrl', () => {
afterEach(() => {
vi.resetModules()
})

test('converts path to WSL file URL when in WSL environment', async () => {
// Given
const originalEnv = process.env.WSL_DISTRO_NAME
process.env.WSL_DISTRO_NAME = 'Ubuntu'

// Reset modules and mock is-wsl module
vi.resetModules()
vi.doMock('is-wsl', () => ({default: true}))

// Re-import to get fresh module with mocked dependency
const {convertToWslFileUrl} = await import('./system.js')

// When
const got = await convertToWslFileUrl('/tmp/test-file.html')

// Then
expect(got).toBe('file://wsl.localhost/Ubuntu/tmp/test-file.html')

// Cleanup
process.env.WSL_DISTRO_NAME = originalEnv
})

test('converts home directory path to WSL file URL', async () => {
// Given
const originalEnv = process.env.WSL_DISTRO_NAME
process.env.WSL_DISTRO_NAME = 'Ubuntu-22.04'

// Reset modules and mock is-wsl module
vi.resetModules()
vi.doMock('is-wsl', () => ({default: true}))

// Re-import to get fresh module with mocked dependency
const {convertToWslFileUrl} = await import('./system.js')

// When
const got = await convertToWslFileUrl('/home/user/.nvm/versions/node/v20/lib/file.js')

// Then
expect(got).toBe('file://wsl.localhost/Ubuntu-22.04/home/user/.nvm/versions/node/v20/lib/file.js')

// Cleanup
process.env.WSL_DISTRO_NAME = originalEnv
})

test('returns standard file URL when not in WSL', async () => {
// Given
vi.resetModules()
vi.doMock('is-wsl', () => ({default: false}))

// Re-import to get fresh module with mocked dependency
const {convertToWslFileUrl} = await import('./system.js')

// When
const got = await convertToWslFileUrl('/tmp/test-file.html')

// Then
expect(got).toBe('file:///tmp/test-file.html')
})

test('returns standard file URL when WSL_DISTRO_NAME is not set', async () => {
// Given
const originalEnv = process.env.WSL_DISTRO_NAME
delete process.env.WSL_DISTRO_NAME

// Reset modules and mock is-wsl to return true, but distro name is not set
vi.resetModules()
vi.doMock('is-wsl', () => ({default: true}))

// Re-import to get fresh module with mocked dependency
const {convertToWslFileUrl} = await import('./system.js')

// When
const got = await convertToWslFileUrl('/tmp/test-file.html')

// Then
expect(got).toBe('file:///tmp/test-file.html')

// Cleanup
process.env.WSL_DISTRO_NAME = originalEnv
})
})
Loading