From c65439970d31449d3942682e43a670e5a1a35197 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 24 Mar 2026 20:30:15 +0100 Subject: [PATCH 1/3] Normalize drive letter to lowercase on Windows to match VSCode --- src/spec-node/featuresCLI/testCommandImpl.ts | 5 +- src/spec-node/utils.ts | 90 ++++++++++++++++++-- src/test/labelPathNormalization.test.ts | 29 +++++++ 3 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/test/labelPathNormalization.test.ts diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index fc9f092fb..d119885a8 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -6,7 +6,7 @@ import { CLIHost } from '../../spec-common/cliHost'; import { launch, ProvisionOptions, createDockerParams } from '../devContainers'; import { doExec } from '../devContainersSpecCLI'; import { LaunchResult, staticExecParams, staticProvisionParams, testLibraryScript } from './utils'; -import { DockerResolverParameters } from '../utils'; +import { DockerResolverParameters, normalizeDevContainerLabelPath } from '../utils'; import { DevContainerConfig } from '../../spec-configuration/configuration'; import { FeaturesTestCommandInput } from './test'; import { cpDirectoryLocal, rmLocal } from '../../spec-utils/pfs'; @@ -546,7 +546,8 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder: const { common } = params; let response = {} as LaunchResult; - const idLabels = [`devcontainer.local_folder=${workspaceFolder}`, `devcontainer.is_test_run=true`]; + const normalizedWorkspaceFolder = normalizeDevContainerLabelPath(process.platform, workspaceFolder); + const idLabels = [`devcontainer.local_folder=${normalizedWorkspaceFolder}`, `devcontainer.is_test_run=true`]; const options: ProvisionOptions = { ...staticProvisionParams, workspaceFolder, diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 74817d1a0..5cd6532df 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -15,7 +15,7 @@ import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, import { Workspace } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; import { ShellServer } from '../spec-common/shellServer'; -import { inspectContainer, inspectImage, getEvents, 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 } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; @@ -614,6 +614,72 @@ export function getEmptyContextFolder(common: ResolverParameters) { return common.cliHost.path.join(common.persistedFolder, 'empty-folder'); } +export function normalizeDevContainerLabelPath(platform: NodeJS.Platform, value: string): string { + if (platform !== 'win32') { + return value; + } + + // Normalize separators and dot segments, then explicitly lowercase the drive + // letter because devcontainer.local_folder / devcontainer.config_file labels + // should compare case-insensitively on Windows. + const normalized = path.win32.normalize(value); + if (normalized.length >= 2 && normalized[1] === ':') { + return normalized[0].toLowerCase() + normalized.slice(1); + } + + return normalized; +} + +async function findDevContainerByNormalizedLabels(params: DockerResolverParameters | DockerCLIParameters, normalizedWorkspaceFolder: string, normalizedConfigFile: string) { + if (process.platform !== 'win32') { + return undefined; + } + + const ids = await listContainers(params, true, [hostFolderLabel]); + if (!ids.length) { + return undefined; + } + + const details = await inspectContainers(params, ids); + return details + .filter(container => container.State.Status !== 'removing') + .find(container => { + const labels = container.Config.Labels || {}; + const containerWorkspaceFolder = labels[hostFolderLabel]; + if (!containerWorkspaceFolder || normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) !== normalizedWorkspaceFolder) { + return false; + } + + const containerConfigFile = labels[configFileLabel]; + return !!containerConfigFile + && normalizeDevContainerLabelPath('win32', containerConfigFile) === normalizedConfigFile; + }); +} + +async function findLegacyDevContainerByNormalizedWorkspaceFolder(params: DockerResolverParameters | DockerCLIParameters, normalizedWorkspaceFolder: string) { + if (process.platform !== 'win32') { + return undefined; + } + + const ids = await listContainers(params, true, [hostFolderLabel]); + if (!ids.length) { + return undefined; + } + + const details = await inspectContainers(params, ids); + return details + .filter(container => container.State.Status !== 'removing') + .find(container => { + const labels = container.Config.Labels || {}; + const containerWorkspaceFolder = labels[hostFolderLabel]; + if (!containerWorkspaceFolder) { + return false; + } + + return normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) === normalizedWorkspaceFolder; + }); +} + export async function findContainerAndIdLabels(params: DockerResolverParameters | DockerCLIParameters, containerId: string | undefined, providedIdLabels: string[] | undefined, workspaceFolder: string | undefined, configFile: string | undefined, removeContainerWithOldLabels?: boolean | string) { if (providedIdLabels) { return { @@ -621,14 +687,26 @@ export async function findContainerAndIdLabels(params: DockerResolverParameters idLabels: providedIdLabels, }; } + + const normalizedWorkspaceFolder = workspaceFolder ? normalizeDevContainerLabelPath(process.platform, workspaceFolder) : workspaceFolder; + const normalizedConfigFile = configFile ? normalizeDevContainerLabelPath(process.platform, configFile) : configFile; + const newLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`, `${configFileLabel}=${normalizedConfigFile}`]; + const oldLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`]; + let container: ContainerDetails | undefined; if (containerId) { container = await inspectContainer(params, containerId); - } else if (workspaceFolder && configFile) { - container = await findDevContainer(params, [`${hostFolderLabel}=${workspaceFolder}`, `${configFileLabel}=${configFile}`]); + } else if (normalizedWorkspaceFolder && normalizedConfigFile) { + container = await findDevContainer(params, newLabels); + if (!container) { + container = await findDevContainerByNormalizedLabels(params, normalizedWorkspaceFolder, normalizedConfigFile); + } if (!container) { // Fall back to old labels. - container = await findDevContainer(params, [`${hostFolderLabel}=${workspaceFolder}`]); + container = await findDevContainer(params, oldLabels); + if (!container) { + container = await findLegacyDevContainerByNormalizedWorkspaceFolder(params, normalizedWorkspaceFolder); + } if (container) { if (container.Config.Labels?.[configFileLabel]) { // But ignore containers with new labels. @@ -645,9 +723,7 @@ export async function findContainerAndIdLabels(params: DockerResolverParameters } return { container, - idLabels: !container || container.Config.Labels?.[configFileLabel] ? - [`${hostFolderLabel}=${workspaceFolder}`, `${configFileLabel}=${configFile}`] : - [`${hostFolderLabel}=${workspaceFolder}`], + idLabels: !container || container.Config.Labels?.[configFileLabel] ? newLabels : oldLabels, }; } diff --git a/src/test/labelPathNormalization.test.ts b/src/test/labelPathNormalization.test.ts new file mode 100644 index 000000000..2997b5632 --- /dev/null +++ b/src/test/labelPathNormalization.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import { normalizeDevContainerLabelPath } from '../spec-node/utils'; + +describe('normalizeDevContainerLabelPath', function () { + it('lowercases Windows drive letters', function () { + assert.equal( + normalizeDevContainerLabelPath('win32', 'C:\\CodeBlocks\\remill'), + 'c:\\CodeBlocks\\remill' + ); + }); + + it('normalizes Windows path separators', function () { + assert.equal( + normalizeDevContainerLabelPath('win32', 'C:/CodeBlocks/remill/.devcontainer/devcontainer.json'), + 'c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json' + ); + }); + + it('leaves non-Windows paths unchanged', function () { + assert.equal( + normalizeDevContainerLabelPath('linux', '/workspaces/remill'), + '/workspaces/remill' + ); + }); +}); From 87424736973e2a70c23a2041d88085faeae0436c Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Wed, 8 Apr 2026 13:03:05 +0200 Subject: [PATCH 2/3] Increase timeouts for flaky tests --- src/test/container-features/containerFeaturesOCI.test.ts | 4 +++- src/test/container-features/featureHelpers.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index 52e6fde10..9281a7498 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -255,7 +255,9 @@ describe('getRef()', async function () { }); describe('Test OCI Pull', async function () { - this.timeout('10s'); + // These tests fetch manifests/blobs from a live OCI registry, so allow + // extra time for auth/token exchange and transient network latency in CI. + this.timeout('60s'); it('Parse OCI identifier', async function () { const feat = getRef(output, 'ghcr.io/codspace/features/ruby:1'); diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index d4ceeb8a8..3e08c0648 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -57,7 +57,9 @@ describe('validate processFeatureIdentifier', async function () { console.log(`workspaceRoot = ${workspaceRoot}, defaultConfigPath = ${defaultConfigPath}`); describe('VALID processFeatureIdentifier examples', async function () { - this.timeout('4s'); + // These cases perform live OCI/GHCR requests, so allow extra time for + // registry auth/token exchange and transient network latency in CI. + this.timeout('20s'); it('should process v1 local-cache', async function () { // Parsed out of a user's devcontainer.json From 70801d5ff3ab73534818256cb8d7a2df1699a848 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Wed, 8 Apr 2026 14:28:46 +0200 Subject: [PATCH 3/3] Address PR review nits --- src/spec-node/utils.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 5cd6532df..2fafbca41 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -646,13 +646,15 @@ async function findDevContainerByNormalizedLabels(params: DockerResolverParamete .find(container => { const labels = container.Config.Labels || {}; const containerWorkspaceFolder = labels[hostFolderLabel]; - if (!containerWorkspaceFolder || normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) !== normalizedWorkspaceFolder) { + const normalizedContainerWorkspaceFolder = containerWorkspaceFolder && normalizeDevContainerLabelPath('win32', containerWorkspaceFolder); + if (!normalizedContainerWorkspaceFolder || normalizedContainerWorkspaceFolder !== normalizedWorkspaceFolder) { return false; } const containerConfigFile = labels[configFileLabel]; - return !!containerConfigFile - && normalizeDevContainerLabelPath('win32', containerConfigFile) === normalizedConfigFile; + const normalizedContainerConfigFile = containerConfigFile && normalizeDevContainerLabelPath('win32', containerConfigFile); + return !!normalizedContainerConfigFile + && normalizedContainerConfigFile === normalizedConfigFile; }); } @@ -672,11 +674,8 @@ async function findLegacyDevContainerByNormalizedWorkspaceFolder(params: DockerR .find(container => { const labels = container.Config.Labels || {}; const containerWorkspaceFolder = labels[hostFolderLabel]; - if (!containerWorkspaceFolder) { - return false; - } - - return normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) === normalizedWorkspaceFolder; + const normalizedContainerWorkspaceFolder = containerWorkspaceFolder && normalizeDevContainerLabelPath('win32', containerWorkspaceFolder); + return normalizedContainerWorkspaceFolder === normalizedWorkspaceFolder; }); } @@ -690,8 +689,8 @@ export async function findContainerAndIdLabels(params: DockerResolverParameters const normalizedWorkspaceFolder = workspaceFolder ? normalizeDevContainerLabelPath(process.platform, workspaceFolder) : workspaceFolder; const normalizedConfigFile = configFile ? normalizeDevContainerLabelPath(process.platform, configFile) : configFile; - const newLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`, `${configFileLabel}=${normalizedConfigFile}`]; const oldLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`]; + const newLabels = [...oldLabels, `${configFileLabel}=${normalizedConfigFile}`]; let container: ContainerDetails | undefined; if (containerId) {