From 38c26d6e3b413e74a844601fb29a5b402d292333 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:43:49 +0000 Subject: [PATCH 1/7] fix: hand off after blocking devcontainer setup --- .../parts/DevContainerCli/DevContainerCli.ts | 1 + .../test/DevContainerCli.test.ts | 1 + .../.devcontainer/Dockerfile | 2 + .../.devcontainer/devcontainer.json | 10 ++++ .../fixtures/reopen-persistent/host-only.txt | 1 + ...vcontainer.reopen-persistent-post-start.ts | 53 +++++++++++++++++++ 6 files changed, 68 insertions(+) create mode 100644 packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile create mode 100644 packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json create mode 100644 packages/e2e/fixtures/reopen-persistent/host-only.txt create mode 100644 packages/e2e/src/devcontainer.reopen-persistent-post-start.ts diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index d92788e..72bdc41 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -90,6 +90,7 @@ export const getCliUpArgs = ({ }: WorkspaceOptions) => { return [ 'up', + '--skip-non-blocking-commands', '--workspace-folder', workspaceFolder, '--no-lockfile', diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index 57516f4..82969d1 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -25,6 +25,7 @@ test('getCliUpArgs', () => { }), ).toEqual([ 'up', + '--skip-non-blocking-commands', '--workspace-folder', '/workspace', '--no-lockfile', diff --git a/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile b/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile new file mode 100644 index 0000000..022fd8f --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile @@ -0,0 +1,2 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu24.04 +RUN mkdir /container-workspace && printf 'created inside the devcontainer\n' > /container-workspace/container-only.txt diff --git a/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json b/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json new file mode 100644 index 0000000..dec1ecd --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "Reopen persistent post-start command", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/host-workspace,type=bind", + "workspaceFolder": "/container-workspace", + "remoteUser": "root", + "postStartCommand": "sh -c 'echo started > /tmp/devcontainer-post-start && echo $$ > /tmp/devcontainer-post-start.pid && exec sleep 300'" +} diff --git a/packages/e2e/fixtures/reopen-persistent/host-only.txt b/packages/e2e/fixtures/reopen-persistent/host-only.txt new file mode 100644 index 0000000..007bfb9 --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/host-only.txt @@ -0,0 +1 @@ +This file exists only in the host workspace. diff --git a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts new file mode 100644 index 0000000..a712cdf --- /dev/null +++ b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts @@ -0,0 +1,53 @@ +import type { Test } from '@lvce-editor/test-worker' +import { getWorkspaceUri } from '../helpers/getWorkspaceUri.ts' +import { waitForContainerWorkspace } from '../helpers/waitForContainerWorkspace.ts' + +export const name = 'devcontainer.reopen-persistent-post-start' + +export const test: Test = async ({ + Command, + Devcontainer, + expect, + Locator, + QuickPick, + Workspace, +}) => { + const localUri = await getWorkspaceUri({ Command }, 'reopen-persistent') + await Workspace.setPath(localUri) + try { + await QuickPick.open() + await QuickPick.setValue('>Dev Containers: Reopen in Container') + await QuickPick.selectItem('Dev Containers: Reopen in Container') + const workspaceUri = await waitForContainerWorkspace({ Command }) + const containerFile = Locator( + '.Explorer .TreeItem[aria-label="container-only.txt"]', + ) + await expect(containerFile).toBeVisible() + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/devcontainer-post-start'], + 'started', + ) + await Devcontainer.shouldHaveExecOutput( + 'sh', + [ + '-c', + 'kill -0 "$(cat /tmp/devcontainer-post-start.pid)" && echo running', + ], + 'running', + ) + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/container-workspace/container-only.txt'], + 'created inside the devcontainer\n', + ) + if ((await Command.execute('Workspace.getUri')) !== workspaceUri) { + throw new Error( + 'The persistent post-start command blocked workspace handoff', + ) + } + } finally { + await Workspace.setPath(localUri) + await Devcontainer.remove() + } +} From cc1f2024a99f88448e511126a7d1282122714037 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:48:52 +0000 Subject: [PATCH 2/7] fix: preserve post-create readiness while backgrounding post-start --- .../parts/DevContainerCli/DevContainerCli.ts | 62 ++++++++++++++++--- .../test/DevContainerCli.test.ts | 25 ++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index 72bdc41..bb8b87c 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -1,4 +1,7 @@ import { createRequire } from 'node:module' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { ErrorResult } from '../SerializeError/SerializeError.ts' import * as CliError from '../CliError/CliError.ts' import * as CliJson from '../CliJson/CliJson.ts' @@ -43,6 +46,10 @@ export interface WorkspaceOptions { workspaceFolder: string } +interface CliUpOptions extends WorkspaceOptions { + overrideConfig?: string +} + export interface ExecOptions extends WorkspaceOptions { args?: readonly string[] command: string @@ -86,11 +93,13 @@ export const getCliReadConfigurationArgs = ({ export const getCliUpArgs = ({ containerCli = dockerPath, + overrideConfig, workspaceFolder, -}: WorkspaceOptions) => { +}: CliUpOptions) => { return [ 'up', '--skip-non-blocking-commands', + ...(overrideConfig ? ['--override-config', overrideConfig] : []), '--workspace-folder', workspaceFolder, '--no-lockfile', @@ -104,6 +113,19 @@ export const getCliUpArgs = ({ ] } +export const getDefaultWaitForOverride = ( + configuration: unknown, +): { waitFor: string } | undefined => { + if ( + !configuration || + typeof configuration !== 'object' || + Object.hasOwn(configuration, 'waitFor') + ) { + return undefined + } + return { waitFor: 'postCreateCommand' } +} + export const getCliExecArgs = ({ args = [], command, @@ -276,12 +298,38 @@ export const cliReadConfiguration = (options: WorkspaceOptions) => { export const cliUp = async ( options: WorkspaceOptions, ): Promise => { - const result = await runDevcontainerCli( - 'DevContainerNode.cliUp', - [getDevcontainerCliPath(), ...getCliUpArgs(options)], - options.containerCli, - options.onOutput, - ) + const configurationResult = await cliReadConfiguration(options) + const configuration = ( + configurationResult.json as { configuration?: unknown } | undefined + )?.configuration + const waitForOverride = configurationResult.ok + ? getDefaultWaitForOverride(configuration) + : undefined + let overrideConfigDirectory: string | undefined + let result: CliCommandResult + try { + let overrideConfig: string | undefined + if (waitForOverride) { + overrideConfigDirectory = await mkdtemp( + join(tmpdir(), 'lvce-devcontainer-up-'), + ) + overrideConfig = join(overrideConfigDirectory, 'devcontainer.json') + await writeFile(overrideConfig, JSON.stringify(waitForOverride)) + } + result = await runDevcontainerCli( + 'DevContainerNode.cliUp', + [ + getDevcontainerCliPath(), + ...getCliUpArgs({ ...options, overrideConfig }), + ], + options.containerCli, + options.onOutput, + ) + } finally { + if (overrideConfigDirectory) { + await rm(overrideConfigDirectory, { force: true, recursive: true }) + } + } if (!result.ok) return result const json = result.json as { containerId: string diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index 82969d1..4c6360b 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -39,6 +39,31 @@ test('getCliUpArgs', () => { ]) }) +test('getCliUpArgs accepts a lifecycle readiness override', () => { + expect( + DevContainerCli.getCliUpArgs({ + overrideConfig: '/tmp/devcontainer-override.json', + workspaceFolder: '/workspace', + }), + ).toContain('--override-config') + expect( + DevContainerCli.getCliUpArgs({ + overrideConfig: '/tmp/devcontainer-override.json', + workspaceFolder: '/workspace', + }), + ).toContain('/tmp/devcontainer-override.json') +}) + +test('getDefaultWaitForOverride waits through post-create only when unset', () => { + expect(DevContainerCli.getDefaultWaitForOverride({})).toEqual({ + waitFor: 'postCreateCommand', + }) + expect( + DevContainerCli.getDefaultWaitForOverride({ waitFor: 'postStartCommand' }), + ).toBeUndefined() + expect(DevContainerCli.getDefaultWaitForOverride(undefined)).toBeUndefined() +}) + test('getCliExecArgs', () => { expect( DevContainerCli.getCliExecArgs({ From 029dbfb2aec7d4ace7001a2c22272b5d12b701b4 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:55:44 +0000 Subject: [PATCH 3/7] fix: preserve merged config for background startup --- .../parts/DevContainerCli/DevContainerCli.ts | 31 +++++++++++++++---- .../test/DevContainerCli.test.ts | 29 ++++++++++++++--- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index bb8b87c..59f3a85 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -1,5 +1,5 @@ -import { createRequire } from 'node:module' import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ErrorResult } from '../SerializeError/SerializeError.ts' @@ -48,6 +48,7 @@ export interface WorkspaceOptions { interface CliUpOptions extends WorkspaceOptions { overrideConfig?: string + skipNonBlockingCommands?: boolean } export interface ExecOptions extends WorkspaceOptions { @@ -86,6 +87,7 @@ export const getCliReadConfigurationArgs = ({ 'read-configuration', '--workspace-folder', workspaceFolder, + '--include-merged-configuration', '--docker-path', containerCli, ] @@ -94,11 +96,12 @@ export const getCliReadConfigurationArgs = ({ export const getCliUpArgs = ({ containerCli = dockerPath, overrideConfig, + skipNonBlockingCommands = false, workspaceFolder, }: CliUpOptions) => { return [ 'up', - '--skip-non-blocking-commands', + ...(skipNonBlockingCommands ? ['--skip-non-blocking-commands'] : []), ...(overrideConfig ? ['--override-config', overrideConfig] : []), '--workspace-folder', workspaceFolder, @@ -115,7 +118,8 @@ export const getCliUpArgs = ({ export const getDefaultWaitForOverride = ( configuration: unknown, -): { waitFor: string } | undefined => { + mergedConfiguration: unknown, +): Record | undefined => { if ( !configuration || typeof configuration !== 'object' || @@ -123,7 +127,10 @@ export const getDefaultWaitForOverride = ( ) { return undefined } - return { waitFor: 'postCreateCommand' } + if (!mergedConfiguration || typeof mergedConfiguration !== 'object') { + return undefined + } + return { ...mergedConfiguration, waitFor: 'postCreateCommand' } } export const getCliExecArgs = ({ @@ -302,9 +309,17 @@ export const cliUp = async ( const configuration = ( configurationResult.json as { configuration?: unknown } | undefined )?.configuration + const mergedConfiguration = ( + configurationResult.json as { mergedConfiguration?: unknown } | undefined + )?.mergedConfiguration + const hasExplicitWaitFor = + configuration !== null && + typeof configuration === 'object' && + Object.hasOwn(configuration, 'waitFor') const waitForOverride = configurationResult.ok - ? getDefaultWaitForOverride(configuration) + ? getDefaultWaitForOverride(configuration, mergedConfiguration) : undefined + const skipNonBlockingCommands = hasExplicitWaitFor || Boolean(waitForOverride) let overrideConfigDirectory: string | undefined let result: CliCommandResult try { @@ -320,7 +335,11 @@ export const cliUp = async ( 'DevContainerNode.cliUp', [ getDevcontainerCliPath(), - ...getCliUpArgs({ ...options, overrideConfig }), + ...getCliUpArgs({ + ...options, + overrideConfig, + skipNonBlockingCommands, + }), ], options.containerCli, options.onOutput, diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index 4c6360b..0f6d392 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -13,6 +13,7 @@ test('getCliReadConfigurationArgs', () => { 'read-configuration', '--workspace-folder', '/workspace', + '--include-merged-configuration', '--docker-path', 'docker', ]) @@ -25,7 +26,6 @@ test('getCliUpArgs', () => { }), ).toEqual([ 'up', - '--skip-non-blocking-commands', '--workspace-folder', '/workspace', '--no-lockfile', @@ -39,6 +39,15 @@ test('getCliUpArgs', () => { ]) }) +test('getCliUpArgs enables background lifecycle commands when requested', () => { + expect( + DevContainerCli.getCliUpArgs({ + skipNonBlockingCommands: true, + workspaceFolder: '/workspace', + }), + ).toContain('--skip-non-blocking-commands') +}) + test('getCliUpArgs accepts a lifecycle readiness override', () => { expect( DevContainerCli.getCliUpArgs({ @@ -55,13 +64,25 @@ test('getCliUpArgs accepts a lifecycle readiness override', () => { }) test('getDefaultWaitForOverride waits through post-create only when unset', () => { - expect(DevContainerCli.getDefaultWaitForOverride({})).toEqual({ + expect( + DevContainerCli.getDefaultWaitForOverride( + {}, + { features: { 'example/feature': {} }, image: 'alpine' }, + ), + ).toEqual({ + features: { 'example/feature': {} }, + image: 'alpine', waitFor: 'postCreateCommand', }) expect( - DevContainerCli.getDefaultWaitForOverride({ waitFor: 'postStartCommand' }), + DevContainerCli.getDefaultWaitForOverride( + { waitFor: 'postStartCommand' }, + { image: 'alpine' }, + ), + ).toBeUndefined() + expect( + DevContainerCli.getDefaultWaitForOverride({}, undefined), ).toBeUndefined() - expect(DevContainerCli.getDefaultWaitForOverride(undefined)).toBeUndefined() }) test('getCliExecArgs', () => { From cb7e951674eba86fbe8e5bc3b568a40919f8b57d Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:59:14 +0000 Subject: [PATCH 4/7] fix: retain setup commands in lifecycle override --- .../parts/DevContainerCli/DevContainerCli.ts | 12 ++--------- .../test/DevContainerCli.test.ts | 20 ++++++++----------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index 59f3a85..bf70831 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -87,7 +87,6 @@ export const getCliReadConfigurationArgs = ({ 'read-configuration', '--workspace-folder', workspaceFolder, - '--include-merged-configuration', '--docker-path', containerCli, ] @@ -118,7 +117,6 @@ export const getCliUpArgs = ({ export const getDefaultWaitForOverride = ( configuration: unknown, - mergedConfiguration: unknown, ): Record | undefined => { if ( !configuration || @@ -127,10 +125,7 @@ export const getDefaultWaitForOverride = ( ) { return undefined } - if (!mergedConfiguration || typeof mergedConfiguration !== 'object') { - return undefined - } - return { ...mergedConfiguration, waitFor: 'postCreateCommand' } + return { ...configuration, waitFor: 'postCreateCommand' } } export const getCliExecArgs = ({ @@ -309,15 +304,12 @@ export const cliUp = async ( const configuration = ( configurationResult.json as { configuration?: unknown } | undefined )?.configuration - const mergedConfiguration = ( - configurationResult.json as { mergedConfiguration?: unknown } | undefined - )?.mergedConfiguration const hasExplicitWaitFor = configuration !== null && typeof configuration === 'object' && Object.hasOwn(configuration, 'waitFor') const waitForOverride = configurationResult.ok - ? getDefaultWaitForOverride(configuration, mergedConfiguration) + ? getDefaultWaitForOverride(configuration) : undefined const skipNonBlockingCommands = hasExplicitWaitFor || Boolean(waitForOverride) let overrideConfigDirectory: string | undefined diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index 0f6d392..f7bc21f 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -13,7 +13,6 @@ test('getCliReadConfigurationArgs', () => { 'read-configuration', '--workspace-folder', '/workspace', - '--include-merged-configuration', '--docker-path', 'docker', ]) @@ -65,24 +64,21 @@ test('getCliUpArgs accepts a lifecycle readiness override', () => { test('getDefaultWaitForOverride waits through post-create only when unset', () => { expect( - DevContainerCli.getDefaultWaitForOverride( - {}, - { features: { 'example/feature': {} }, image: 'alpine' }, - ), + DevContainerCli.getDefaultWaitForOverride({ + features: { 'example/feature': {} }, + image: 'alpine', + postCreateCommand: 'npm install', + }), ).toEqual({ features: { 'example/feature': {} }, image: 'alpine', + postCreateCommand: 'npm install', waitFor: 'postCreateCommand', }) expect( - DevContainerCli.getDefaultWaitForOverride( - { waitFor: 'postStartCommand' }, - { image: 'alpine' }, - ), - ).toBeUndefined() - expect( - DevContainerCli.getDefaultWaitForOverride({}, undefined), + DevContainerCli.getDefaultWaitForOverride({ waitFor: 'postStartCommand' }), ).toBeUndefined() + expect(DevContainerCli.getDefaultWaitForOverride(undefined)).toBeUndefined() }) test('getCliExecArgs', () => { From 7ebe267ca39e285040a2f5cf3eaa770e1ebdc649 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:16:29 +0000 Subject: [PATCH 5/7] fix: resume deferred devcontainer lifecycle commands --- .../src/devcontainerCli.d.ts | 6 + .../parts/DevContainerCli/DevContainerCli.ts | 21 +++- .../LifecycleCommands/LifecycleCommands.ts | 119 ++++++++++++++++++ .../test/DevContainerCli.test.ts | 2 +- .../test/LifecycleCommands.test.ts | 96 ++++++++++++++ .../src/devcontainerWorkerModule.ts | 5 + ...vcontainer.reopen-persistent-post-start.ts | 37 +++++- .../extension/src/parts/Progress/Progress.ts | 44 ++++++- .../test/DevContainerProgress.test.ts | 35 +++++- 9 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts create mode 100644 packages/devcontainer-node/test/LifecycleCommands.test.ts diff --git a/packages/devcontainer-node/src/devcontainerCli.d.ts b/packages/devcontainer-node/src/devcontainerCli.d.ts index 91bb942..628a1b4 100644 --- a/packages/devcontainer-node/src/devcontainerCli.d.ts +++ b/packages/devcontainer-node/src/devcontainerCli.d.ts @@ -41,3 +41,9 @@ export function forwardPorts( }, ): Promise export function disposeForwardPorts(): void + +export function getLifecycleProgress(workspaceFolder: string): { + errorMessage?: string + output: string + running: boolean +} diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index bf70831..5c27bca 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -7,6 +7,7 @@ import * as CliError from '../CliError/CliError.ts' import * as CliJson from '../CliJson/CliJson.ts' import * as ContainerFileSystem from '../ContainerFileSystem/ContainerFileSystem.ts' import * as ForwardPorts from '../ForwardPorts/ForwardPorts.ts' +import * as LifecycleCommands from '../LifecycleCommands/LifecycleCommands.ts' import * as RunProcess from '../RunProcess/RunProcess.ts' export interface CliCommandSuccess { @@ -347,6 +348,7 @@ export const cliUp = async ( remoteUser?: string mergedConfiguration?: { forwardPorts?: unknown } } + let setupErrorCode = 'DEVCONTAINER_FORWARD_PORTS_ERROR' try { await ForwardPorts.ensure({ containerCli: options.containerCli ?? dockerPath, @@ -355,11 +357,20 @@ export const cliUp = async ( remoteUser: json.remoteUser, workspaceFolder: options.workspaceFolder, }) + if (skipNonBlockingCommands) { + setupErrorCode = 'DEVCONTAINER_LIFECYCLE_ERROR' + await LifecycleCommands.start( + options.workspaceFolder, + json.containerId, + getDevcontainerCliPath(), + options.containerCli ?? dockerPath, + ) + } return result } catch (error) { return { commandName: result.commandName, - errorCode: 'DEVCONTAINER_FORWARD_PORTS_ERROR', + errorCode: setupErrorCode, errorMessage: error instanceof Error ? error.message : String(error), errorStack: undefined, json: result.json, @@ -377,7 +388,11 @@ export const forwardPorts = ( remoteUser?: string }, ): Promise => ForwardPorts.ensure(options) -export const disposeForwardPorts = ForwardPorts.dispose +export const disposeForwardPorts = () => { + LifecycleCommands.dispose() + ForwardPorts.dispose() +} +export const getLifecycleProgress = LifecycleCommands.getProgress export const cliExec = (options: ExecOptions) => { return runDevcontainerCommand( @@ -388,6 +403,7 @@ export const cliExec = (options: ExecOptions) => { } export const dockerStopContainer = async (options: ContainerOptions) => { + LifecycleCommands.remove(options.containerId) await ForwardPorts.remove(options.containerId) return runDocker( 'DevContainerNode.dockerStopContainer', @@ -397,6 +413,7 @@ export const dockerStopContainer = async (options: ContainerOptions) => { } export const dockerRemoveContainer = async (options: ContainerOptions) => { + LifecycleCommands.remove(options.containerId) await ForwardPorts.remove(options.containerId) return runDocker( 'DevContainerNode.dockerRemoveContainer', diff --git a/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts b/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts new file mode 100644 index 0000000..e48bd1f --- /dev/null +++ b/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts @@ -0,0 +1,119 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawn } from 'node:child_process' +import { join } from 'node:path' + +interface Operation { + child: ChildProcessWithoutNullStreams + containerId: string + errorMessage?: string + output: string + running: boolean +} + +const operations = new Map() +const limit = 1024 * 1024 + +export const getProgress = (workspaceFolder: string) => { + const value = operations.get(workspaceFolder) + return { + errorMessage: value?.errorMessage, + output: value?.output ?? '', + running: value?.running ?? false, + } +} + +const terminate = (value: Operation): void => { + if (!value.running || !value.child.pid) return + if (process.platform === 'win32') { + spawn( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/pid', String(value.child.pid), '/T', '/F'], + { + stdio: 'ignore', + }, + ).on('error', () => value.child.kill()) + } else { + try { + process.kill(-value.child.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } +} + +export const remove = (containerId: string): void => { + for (const [workspace, value] of operations) { + if (value.containerId !== containerId) continue + operations.delete(workspace) + terminate(value) + } +} + +export const dispose = (): void => { + for (const value of operations.values()) terminate(value) + operations.clear() +} + +export const start = async ( + workspaceFolder: string, + containerId: string, + cliPath: string, + containerCli: string, +): Promise => { + const previous = operations.get(workspaceFolder) + if (previous?.running && previous.containerId === containerId) return + if (previous) { + operations.delete(workspaceFolder) + terminate(previous) + } + const child = spawn( + process.execPath, + [ + cliPath, + 'run-user-commands', + '--workspace-folder', + workspaceFolder, + '--container-id', + containerId, + '--docker-path', + containerCli, + '--log-format', + 'text', + ], + { + detached: process.platform !== 'win32', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }, + ) + const value: Operation = { child, containerId, output: '', running: true } + operations.set(workspaceFolder, value) + const append = (chunk: string) => { + value.output = (value.output + chunk).slice(-limit) + } + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', append) + child.stderr.on('data', append) + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') append(String(error)) + }) + child.stdin.end() + child.on('error', (error) => { + value.running = false + value.errorMessage = `Devcontainer lifecycle commands failed: ${error.message}` + append(value.errorMessage) + }) + child.once('close', (code, signal) => { + value.running = false + if (operations.get(workspaceFolder) !== value) return + if (code !== 0) { + const reason = signal ?? `exit code ${code}` + value.errorMessage = `Devcontainer lifecycle commands failed (${reason}). See Dev Containers output.` + append(`\n${value.errorMessage}\n`) + } + }) + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) +} diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index f7bc21f..2795465 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -38,7 +38,7 @@ test('getCliUpArgs', () => { ]) }) -test('getCliUpArgs enables background lifecycle commands when requested', () => { +test('getCliUpArgs stops at the readiness boundary when requested', () => { expect( DevContainerCli.getCliUpArgs({ skipNonBlockingCommands: true, diff --git a/packages/devcontainer-node/test/LifecycleCommands.test.ts b/packages/devcontainer-node/test/LifecycleCommands.test.ts new file mode 100644 index 0000000..cadd385 --- /dev/null +++ b/packages/devcontainer-node/test/LifecycleCommands.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, test } from '@jest/globals' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as LifecycleCommands from '../src/parts/LifecycleCommands/LifecycleCommands.ts' + +const directories: string[] = [] +const fixture = async (script: string): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'lvce-lifecycle-')) + directories.push(directory) + const path = join(directory, 'cli.cjs') + await writeFile(path, script) + return path +} +const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 5000 + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Lifecycle state did not settle') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +afterEach(async () => { + LifecycleCommands.dispose() + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { force: true, recursive: true })), + ) +}) + +test('persistent hooks stream bounded logs without blocking or launching twice', async () => { + const path = await fixture(` + process.stdout.write(JSON.stringify(process.argv.slice(2)) + '\\n'); + process.stdout.write('x'.repeat(2 * 1024 * 1024)); + process.stdout.write('ready'); + setInterval(() => {}, 1000); + `) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + await waitFor(() => + LifecycleCommands.getProgress('/workspace').output.endsWith('ready'), + ) + const before = LifecycleCommands.getProgress('/workspace') + expect(before.running).toBe(true) + expect(before.output).toHaveLength(1024 * 1024) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + expect(LifecycleCommands.getProgress('/workspace')).toEqual(before) + LifecycleCommands.remove('container') + expect(LifecycleCommands.getProgress('/workspace')).toEqual({ + output: '', + running: false, + }) +}) + +test('deferred failures retain diagnostics and allow another operation', async () => { + const path = await fixture(` + console.log(JSON.stringify(process.argv.slice(2))); + console.error('postStartCommand failed'); + process.exitCode = 7; + `) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + await waitFor(() => !LifecycleCommands.getProgress('/workspace').running) + const result = LifecycleCommands.getProgress('/workspace') + expect(result.errorMessage).toContain('exit code 7') + expect(result.output).toContain('postStartCommand failed') + expect(result.output).toContain('run-user-commands') + expect(result.output).toContain('/tools/docker') + await LifecycleCommands.start( + '/workspace', + 'container', + await fixture(`console.log('repaired')`), + '/tools/docker', + ) + await waitFor(() => !LifecycleCommands.getProgress('/workspace').running) + expect( + LifecycleCommands.getProgress('/workspace').errorMessage, + ).toBeUndefined() + expect(LifecycleCommands.getProgress('/workspace').output).toContain( + 'repaired', + ) +}) diff --git a/packages/devcontainer-worker/src/devcontainerWorkerModule.ts b/packages/devcontainer-worker/src/devcontainerWorkerModule.ts index 0418df4..807cba6 100644 --- a/packages/devcontainer-worker/src/devcontainerWorkerModule.ts +++ b/packages/devcontainer-worker/src/devcontainerWorkerModule.ts @@ -7,6 +7,7 @@ import * as DevContainerCommandType from './parts/DevContainerCommandType/DevCon import * as DevContainerNodeClient from './parts/DevContainerNodeClient/DevContainerNodeClient.ts' import * as GetDockerInstallCommand from './parts/GetDockerInstallCommand/GetDockerInstallCommand.ts' import * as Progress from './parts/Progress/Progress.ts' +import * as WorkspaceFolder from './parts/WorkspaceFolder/WorkspaceFolder.ts' let initialized = false const initialize = () => { @@ -85,6 +86,10 @@ export const commandMap = { 'DevContainer.disposeTerminal': ContainerTerminalSession.dispose, 'DevContainer.getDockerInstallCommand': () => GetDockerInstallCommand.getDockerInstallCommand(process.platform), + 'DevContainer.getLifecycleProgress': async (workspaceFolder: string) => + DevContainerCli.getLifecycleProgress( + await WorkspaceFolder.toPath(workspaceFolder), + ), 'DevContainer.getProgress': Progress.getProgress, 'DevContainer.getTerminalSpawnOptions': getTerminalSpawnOptions, [DevContainerCommandType.DevContainerDetect]: detect, diff --git a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts index a712cdf..6af5228 100644 --- a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts +++ b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts @@ -23,10 +23,16 @@ export const test: Test = async ({ '.Explorer .TreeItem[aria-label="container-only.txt"]', ) await expect(containerFile).toBeVisible() + // The readiness boundary precedes the deferred hook. Wait for its actual + // marker, then assert it is alive while the workspace is usable. + await Devcontainer.exec('sh', [ + '-c', + 'for i in $(seq 1 200); do test -f /tmp/devcontainer-post-start && exit 0; sleep 0.1; done; exit 1', + ]) await Devcontainer.shouldHaveExecOutput( 'cat', ['/tmp/devcontainer-post-start'], - 'started', + 'started\n', ) await Devcontainer.shouldHaveExecOutput( 'sh', @@ -34,13 +40,40 @@ export const test: Test = async ({ '-c', 'kill -0 "$(cat /tmp/devcontainer-post-start.pid)" && echo running', ], - 'running', + 'running\n', ) await Devcontainer.shouldHaveExecOutput( 'cat', ['/container-workspace/container-only.txt'], 'created inside the devcontainer\n', ) + await expect(Locator('.Output')).toContainText( + 'Running the postStartCommand', + ) + await Command.execute('Layout.showPanel', 'Terminals') + const terminal = Locator('.XtermTerminal') + await expect(terminal).toBeVisible() + await expect(terminal).toContainText('# ') + await Command.execute( + 'Terminals.sendText', + 'printf "persistent-%s" terminal > /tmp/terminal-acceptance; printf "terminal-%s\\n" ready\r', + ) + await expect(terminal).toContainText('terminal-ready') + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/terminal-acceptance'], + 'persistent-terminal', + ) + const pid = await Devcontainer.exec('cat', [ + '/tmp/devcontainer-post-start.pid', + ]) + await Command.executeExtensionCommand('devcontainer.openWorkspace') + await waitForContainerWorkspace({ Command }) + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/devcontainer-post-start.pid'], + pid, + ) if ((await Command.execute('Workspace.getUri')) !== workspaceUri) { throw new Error( 'The persistent post-start command blocked workspace handoff', diff --git a/packages/extension/src/parts/Progress/Progress.ts b/packages/extension/src/parts/Progress/Progress.ts index 269ef40..c10d3d6 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -2,11 +2,13 @@ import { createOutputChannel, executeCommand, openOutputView, + showNotification, } from '@lvce-editor/api' import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false +let monitorGeneration = 0 const refreshOutput = async (): Promise => { try { @@ -31,6 +33,7 @@ export const run = async ( throw new Error('A Dev Containers operation is already in progress') } busy = true + const generation = ++monitorGeneration try { output ||= createOutputChannel('dev-containers') const channel = output @@ -73,11 +76,19 @@ export const run = async ( } const polling = poll() try { - return await Rpc.invoke(method, { + const result = await Rpc.invoke(method, { containerCli, progressId, workspaceFolder, }) + if (result && typeof result === 'object' && 'ok' in result && result.ok) { + // Lifecycle commands have their own lifetime; they do not hold the + // startup guard while the editor is connected to the container. + setTimeout(() => { + void monitorLifecycle(workspaceFolder, generation) + }, 0) + } + return result } finally { finished = true completed.resolve() @@ -92,3 +103,34 @@ export const run = async ( busy = false } } + +const monitorLifecycle = async ( + workspaceFolder: string, + generation: number, +): Promise => { + let previous = '' + try { + while (generation === monitorGeneration) { + const state = (await Rpc.invoke( + 'DevContainer.getLifecycleProgress', + workspaceFolder, + )) as { errorMessage?: string; output: string; running: boolean } + if (generation !== monitorGeneration) return + if (state.output !== previous) { + const text = state.output.startsWith(previous) + ? state.output.slice(previous.length) + : state.output + previous = state.output + await appendLine(text) + } + if (state.errorMessage) + await showNotification('error', state.errorMessage) + if (!state.running) return + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } catch (error) { + if (generation === monitorGeneration) { + await appendLine(`Unable to read lifecycle progress: ${String(error)}`) + } + } +} diff --git a/packages/extension/test/DevContainerProgress.test.ts b/packages/extension/test/DevContainerProgress.test.ts index 3f39c64..fe64d14 100644 --- a/packages/extension/test/DevContainerProgress.test.ts +++ b/packages/extension/test/DevContainerProgress.test.ts @@ -3,6 +3,12 @@ import { mock, test } from 'node:test' const events: string[] = [] const pending = Promise.withResolvers() +let startupResult: unknown = pending.promise +let lifecycle = { + errorMessage: undefined as string | undefined, + output: '', + running: false, +} mock.module('@lvce-editor/api', { namedExports: { closeUri: async () => {}, @@ -20,7 +26,9 @@ mock.module('@lvce-editor/api', { events.push('output opened') }, openUri: async () => {}, - showNotification: async () => {}, + showNotification: async (_type: string, message: string) => { + events.push(message) + }, }, }) mock.module('../src/parts/Workspace/Workspace.ts', { @@ -30,8 +38,9 @@ mock.module('../src/parts/Rpc/Rpc.ts', { namedExports: { invoke: async (method: string) => { if (method === 'DevContainer.getProgress') return 'Building layer 1\n' + if (method === 'DevContainer.getLifecycleProgress') return lifecycle events.push('build started') - return pending.promise + return startupResult }, }, }) @@ -61,3 +70,25 @@ await test('reopen opens output and displays logs before the build finishes', as ) } }) + +await test('deferred lifecycle output and failures remain visible after startup releases its guard', async () => { + const { run } = await import('../src/parts/Progress/Progress.ts') + startupResult = { ok: true } + lifecycle = { + errorMessage: undefined, + output: 'server started', + running: true, + } + await run('DevContainer.openWorkspace', 'file:///workspace', 'docker') + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.ok(events.includes('server started')) + lifecycle = { + errorMessage: 'postStart failed', + output: 'server started\nexit 7', + running: false, + } + await new Promise((resolve) => setTimeout(resolve, 300)) + assert.ok(events.includes('postStart failed')) + assert.ok(events.some((text) => text.includes('exit 7'))) + await run('DevContainer.openWorkspace', 'file:///workspace', 'docker') +}) From e8dc45e872a7e36744e2dd45fcb25c0bd8b6641c Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:18:59 +0000 Subject: [PATCH 6/7] fix: name the lifecycle output locator --- .../e2e/src/devcontainer.reopen-persistent-post-start.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts index 6af5228..5586886 100644 --- a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts +++ b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts @@ -47,9 +47,8 @@ export const test: Test = async ({ ['/container-workspace/container-only.txt'], 'created inside the devcontainer\n', ) - await expect(Locator('.Output')).toContainText( - 'Running the postStartCommand', - ) + const output = Locator('.Output') + await expect(output).toContainText('Running the postStartCommand') await Command.execute('Layout.showPanel', 'Terminals') const terminal = Locator('.XtermTerminal') await expect(terminal).toBeVisible() From 65dfb0b619f1077c74374ac33bed3fee7c42c0c5 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:27:28 +0000 Subject: [PATCH 7/7] fix: reopen lifecycle output channel for acceptance --- packages/e2e/src/devcontainer.reopen-persistent-post-start.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts index 5586886..8f019a3 100644 --- a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts +++ b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts @@ -47,6 +47,9 @@ export const test: Test = async ({ ['/container-workspace/container-only.txt'], 'created inside the devcontainer\n', ) + // Workspace handoff closes the panel; reopening its existing channel must + // show the deferred hook's retained output. + await Command.execute('Layout.openOutput', 'dev-containers') const output = Locator('.Output') await expect(output).toContainText('Running the postStartCommand') await Command.execute('Layout.showPanel', 'Terminals')