From 2a6cab18bd3bd59f1de0d7bcfc2eccaae52d233f Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Wed, 20 May 2026 17:31:14 -0400 Subject: [PATCH 1/4] Add WSLC support --- src/spec-node/devContainers.ts | 7 ++-- src/spec-node/singleContainer.ts | 56 ++++++++++++++++++++++++-------- src/spec-node/utils.ts | 35 ++++++++++++++++++++ src/spec-shutdown/dockerUtils.ts | 21 +++++++++--- 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index d647bb614..b53f77fa3 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -17,7 +17,7 @@ import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminal import { dockerComposeCLIConfig } from './dockerCompose'; import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; -import { dockerBuildKitVersion, dockerEngineVersion, isPodman } from '../spec-shutdown/dockerUtils'; +import { dockerBuildKitVersion, dockerEngineVersion, isPodman, isWslc } from '../spec-shutdown/dockerUtils'; import { Event } from '../spec-utils/event'; @@ -213,6 +213,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPlatformInfo })); + const detectedWslc = await isWslc({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }); + const dockerEngineVer = await dockerEngineVersion({ cliHost, dockerCLI: dockerPath, @@ -221,13 +223,14 @@ export async function createDockerParams(options: ProvisionOptions, disposables: output, buildPlatformInfo, targetPlatformInfo - }); + }, { useSimpleVersion: detectedWslc }); return { common, parsedAuthority, dockerCLI: dockerPath, isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }), + isWslc: detectedWslc, dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, workspaceMountConsistencyDefault: workspaceMountConsistency, diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..0c371b638 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -347,8 +347,8 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const exposedPorts = typeof appPort === 'number' || typeof appPort === 'string' ? [appPort] : appPort || []; const exposed = ([]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? ['--mount', workspaceMount] : []; - const additionalMount = additionalMountString ? ['--mount', additionalMountString] : []; + const cwdMount = workspaceMount ? (params.isWslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.isWslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -360,24 +360,30 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const containerUserArgs = containerUser ? ['-u', containerUser] : []; const featureArgs: string[] = []; - if (mergedConfig.init) { + // wslc does not support --init, --privileged, --cap-add, or --security-opt + if (mergedConfig.init && !params.isWslc) { featureArgs.push('--init'); } - if (mergedConfig.privileged) { + if (mergedConfig.privileged && !params.isWslc) { featureArgs.push('--privileged'); } - for (const cap of mergedConfig.capAdd || []) { - featureArgs.push('--cap-add', cap); - } - for (const securityOpt of mergedConfig.securityOpt || []) { - featureArgs.push('--security-opt', securityOpt); + if (!params.isWslc) { + for (const cap of mergedConfig.capAdd || []) { + featureArgs.push('--cap-add', cap); + } + for (const securityOpt of mergedConfig.securityOpt || []) { + featureArgs.push('--security-opt', securityOpt); + } } const featureMounts = ([] as string[]).concat( ...[ ...mergedConfig.mounts || [], ...params.additionalMounts, - ].map(m => generateMountCommand(m)) + ].map(m => { + const mountArgs = generateMountCommand(m); + return params.isWslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + }) ); const customEntrypoints = mergedConfig.entrypoints || []; @@ -396,9 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - '--sig-proxy=false', - '-a', 'STDOUT', - '-a', 'STDERR', + ...(params.isWslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -446,6 +450,32 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai return []; } +// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc. +function convertMountToVolume(mountStr: string): string[] { + const parts = new Map(mountStr.split(',').map(p => { + const eq = p.indexOf('='); + return eq === -1 ? [p, ''] : [p.substring(0, eq), p.substring(eq + 1)]; + })); + const source = parts.get('source') || parts.get('src') || ''; + const target = parts.get('target') || parts.get('dst') || parts.get('destination') || ''; + if (source && target) { + return ['-v', `${source}:${target}`]; + } + if (target) { + return ['-v', target]; + } + // Fallback: pass as --mount and let the runtime handle it. + return ['--mount', mountStr]; +} + +// Convert --mount args array (e.g., ['--mount', 'type=bind,...']) to -v syntax for wslc. +function convertMountArgsToVolume(args: string[]): string[] { + if (args.length === 2 && args[0] === '--mount') { + return convertMountToVolume(args[1]); + } + return args; +} + function getLabels(labels: string[]): string[] { let result: string[] = []; labels.forEach(each => result.push('-l', each)); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 515294e7b..74dfb2785 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -10,6 +10,7 @@ import * as os from 'os'; import { ContainerError, toErrorText } from '../spec-common/errors'; import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils'; import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log'; +import { delay } from '../spec-common/async'; import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless'; import { Workspace } from '../spec-utils/workspaces'; @@ -109,6 +110,7 @@ export interface DockerResolverParameters { parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; isPodman: boolean; + isWslc: boolean; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -170,6 +172,9 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { + if (params.isWslc) { + return startEventSeenPolling(params, labels, canceled, output, trace); + } const eventsProcess = await getEvents(params, { event: ['start'] }); return { started: new Promise((resolve, reject) => { @@ -209,6 +214,36 @@ export async function startEventSeen(params: DockerResolverParameters, labels: R }; } +// Polling-based fallback for runtimes that don't support `events` (e.g., wslc). +function startEventSeenPolling(params: DockerResolverParameters, labels: Record, canceled: Promise, output: Log, trace: boolean) { + let stopped = false; + canceled.catch(() => { stopped = true; }); + const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`); + return { + started: new Promise((resolve, reject) => { + canceled.catch(reject); + const poll = async () => { + while (!stopped) { + try { + const containers = await listContainers(params, false, labelFilters); + if (trace) { + output.write(`Log: startEventSeenPolling found ${containers.length} container(s)\r\n`); + } + if (containers.length > 0) { + resolve(); + return; + } + } catch (e) { + // Ignore transient errors during polling. + } + await delay(500); + } + }; + poll(); + }) + }; +} + async function hasLabels(params: DockerResolverParameters, info: any, expectedLabels: Record) { const actualLabels = info.Actor?.Attributes // Docker uses 'id', Podman 'ID'. diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 9f0bce850..4632d266e 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -171,6 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { + const useEvents = !('isWslc' in params && params.isWslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -184,7 +185,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP if (i === n - 1 || !stderr.includes('already in progress')) { throw err; } - if (!removedSeenP) { + if (useEvents && !removedSeenP) { eventsProcess = await getEvents(params, { container: [nameOrId], event: ['destroy'], @@ -197,7 +198,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP }); }); } - await Promise.race([removedSeenP, delay(1000)]); + await Promise.race([removedSeenP || delay(1000), delay(1000)]); } } } finally { @@ -260,13 +261,16 @@ export async function dockerBuildKitVersion(params: DockerCLIParameters | Partia } } -export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters): Promise<{ versionString: string; versionMatch?: string } | undefined> { +export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, options?: { useSimpleVersion?: boolean }): Promise<{ versionString: string; versionMatch?: string } | undefined> { try { const execParams = { ...toExecParameters(params), print: true, }; - const result = await dockerCLI(execParams, 'version', '--format', '{{.Server.Version}}'); + const args: string[] = options?.useSimpleVersion + ? ['version'] + : ['version', '--format', '{{.Server.Version}}']; + const result = await dockerCLI(execParams, ...args); const versionString = result.stdout.toString().trim(); const versionMatch = versionString.match(/(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)/); if (!versionMatch) { @@ -295,6 +299,15 @@ export async function isPodman(params: PartialExecParameters) { } } +export async function isWslc(params: PartialExecParameters) { + try { + const { stdout } = await dockerCLI(params, '-v'); + return stdout.toString().toLowerCase().indexOf('wslc') !== -1; + } catch (err) { + return false; + } +} + export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) { const partial = toPtyExecParameters(params); return runCommand({ From 3864bdaefc5d99b754153dcf9193546b9cdf3e85 Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Thu, 21 May 2026 14:54:54 -0400 Subject: [PATCH 2/4] Updates from PRs --- src/spec-node/containerFeatures.ts | 6 +++--- src/spec-node/devContainers.ts | 9 ++++----- src/spec-node/dockerCompose.ts | 4 ++-- src/spec-node/singleContainer.ts | 18 ++++++++--------- src/spec-node/utils.ts | 7 +++---- src/spec-shutdown/dockerUtils.ts | 32 +++++++++++++++++------------- 6 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index d8912967c..b3d2dff88 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { DevContainerConfig } from '../spec-configuration/configuration'; -import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils'; import { LogLevel, makeLog } from '../spec-utils/log'; import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; @@ -364,7 +364,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise[]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? (params.isWslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; - const additionalMount = additionalMountString ? (params.isWslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; + const cwdMount = workspaceMount ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -361,13 +361,13 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const featureArgs: string[] = []; // wslc does not support --init, --privileged, --cap-add, or --security-opt - if (mergedConfig.init && !params.isWslc) { + if (mergedConfig.init && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--init'); } - if (mergedConfig.privileged && !params.isWslc) { + if (mergedConfig.privileged && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--privileged'); } - if (!params.isWslc) { + if (params.cliVariant !== CLIVariant.Wslc) { for (const cap of mergedConfig.capAdd || []) { featureArgs.push('--cap-add', cap); } @@ -382,7 +382,7 @@ export async function spawnDevContainer(params: DockerResolverParameters, config ...params.additionalMounts, ].map(m => { const mountArgs = generateMountCommand(m); - return params.isWslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + return params.cliVariant === CLIVariant.Wslc ? convertMountArgsToVolume(mountArgs) : mountArgs; }) ); @@ -402,7 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - ...(params.isWslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), + ...(params.cliVariant === CLIVariant.Wslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -436,7 +436,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t } async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise): Promise { - if (params.isPodman && params.common.cliHost.platform === 'linux') { + if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') { const args = ['--security-opt', 'label=disable']; const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg)); if (!hasIdMapping) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 74dfb2785..e6cf6980f 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -16,7 +16,7 @@ import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, import { Workspace } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; import { ShellServer } from '../spec-common/shellServer'; -import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils'; +import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; @@ -109,8 +109,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; - isPodman: boolean; - isWslc: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -172,7 +171,7 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { - if (params.isWslc) { + if (params.cliVariant === CLIVariant.Wslc) { return startEventSeenPolling(params, labels, canceled, output, trace); } const eventsProcess = await getEvents(params, { event: ['start'] }); diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 4632d266e..d1815fe60 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -77,7 +77,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; common: { @@ -171,7 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { - const useEvents = !('isWslc' in params && params.isWslc); + const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -216,7 +216,7 @@ export async function getEvents(params: DockerCLIParameters | PartialExecParamet filterArgs.push('--filter', `${filter}=${value}`); } } - const format = 'isPodman' in params && params.isPodman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 + const format = 'cliVariant' in params && params.cliVariant === CLIVariant.Podman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 const combinedArgs = (args || []).concat(['events', '--format', format, ...filterArgs]); const p = await exec({ @@ -290,22 +290,26 @@ export async function dockerCLI(params: DockerCLIParameters | PartialExecParamet }); } -export async function isPodman(params: PartialExecParameters) { - try { - const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('podman') !== -1; - } catch (err) { - return false; - } +export enum CLIVariant { + Docker = 'docker', + Podman = 'podman', + Wslc = 'wslc', } -export async function isWslc(params: PartialExecParameters) { +export async function lookupCLIVariant(params: PartialExecParameters): Promise { try { const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('wslc') !== -1; - } catch (err) { - return false; + const lower = stdout.toString().toLowerCase(); + if (lower.indexOf('wslc') !== -1) { + return CLIVariant.Wslc; + } + if (lower.indexOf('podman') !== -1) { + return CLIVariant.Podman; + } + } catch (_err) { + // fall through } + return CLIVariant.Docker; } export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) { From f4a636396ca8be13ef57cd4ce8253ab159df9489 Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Mon, 15 Jun 2026 20:58:31 -0400 Subject: [PATCH 3/4] Fixes --- src/spec-node/dockerCompose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index de0384065..b4ddad83e 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -188,7 +188,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf // determine whether we need to extend with features const version = parseVersion((await params.dockerComposeCLI()).version); - const supportsAdditionalBuildContexts = params.cliVariant !== CLIVariant.Podman && version && !isEarlierVersion(version, [2, 17, 0]); + const supportsAdditionalBuildContexts = params.cliVariant !== CLIVariant.Podman && params.cliVariant !== CLIVariant.Wslc && version && !isEarlierVersion(version, [2, 17, 0]); const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); From 328335e72f762b959978e5a0899b11b6262f8494 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 23 Jun 2026 14:58:42 +0200 Subject: [PATCH 4/4] Downgrade runner --- .github/workflows/test-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5ac9743d1..e16642aee 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -18,7 +18,7 @@ permissions: jobs: tests-matrix: name: Tests Matrix (Windows) - runs-on: windows-latest + runs-on: windows-2022 timeout-minutes: 15 strategy: fail-fast: false