Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/vs/platform/agentHost/common/devContainerAgentHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface IDevContainerAgentHostConfig {
readonly connectionId: string;
readonly workspaceFolder: string;
readonly name: string;
/** Whether this user-initiated connection may resume a deliberately stopped container. */
readonly resume?: boolean;
}

/** Serializable connection metadata returned to the renderer. */
Expand Down Expand Up @@ -44,4 +46,6 @@ export interface IDevContainerAgentHostMainService extends IRelayChannel {
isDockerAvailable(): Promise<boolean>;
connect(config: IDevContainerAgentHostConfig): Promise<IDevContainerAgentHostConnectResult>;
disconnect(connectionId: string): Promise<void>;
stopContainer(workspaceFolder: string): Promise<void>;
removeContainer(workspaceFolder: string): Promise<void>;
}
76 changes: 72 additions & 4 deletions src/vs/platform/agentHost/node/devContainerAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Emitter } from '../../../base/common/event.js';
import { FileAccess } from '../../../base/common/network.js';
import { join } from '../../../base/common/path.js';
import { findExecutable } from '../../../base/node/processes.js';
import { SequencerByKey } from '../../../base/common/async.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { vLiteral, vObj, vString } from '../../../base/common/validation.js';
import { localize } from '../../../nls.js';
Expand Down Expand Up @@ -98,8 +99,13 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
private readonly _connections = this._register(new DisposableMap<string, IDevContainerRelay>());
private readonly _connectionStores = this._register(new DisposableMap<string, DisposableStore>());
private readonly _connectionTokenSources = new Map<string, CancellationTokenSource>();
private readonly _connectionWorkspaces = new Map<string, string>();
private readonly _containerIds = new Map<string, string>();
private readonly _suspendedWorkspaces = new Set<string>();
private readonly _containerOperations = new SequencerByKey<string>();
private _nativeRequire: NodeJS.Require | undefined;
private _shellEnvironment: Promise<typeof process.env> | undefined;
private _dockerExecutable: Promise<string | undefined> | undefined;
private _dockerAvailable: Promise<boolean> | undefined;

constructor(
Expand All @@ -112,7 +118,14 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
super();
}

async connect(config: IDevContainerAgentHostConfig): Promise<IDevContainerAgentHostConnectResult> {
connect(config: IDevContainerAgentHostConfig): Promise<IDevContainerAgentHostConnectResult> {
return this._containerOperations.queue(config.workspaceFolder, () => this._connect(config));
}

private async _connect(config: IDevContainerAgentHostConfig): Promise<IDevContainerAgentHostConnectResult> {
if (this._suspendedWorkspaces.has(config.workspaceFolder) && config.resume !== true) {
Comment on lines +122 to +126
throw new Error(localize('devContainerAgentHost.containerSuspended', "Dev Container for '{0}' is stopped.", config.workspaceFolder));
}
await this.disconnect(config.connectionId);
const store = new DisposableStore();
const tokenSource = store.add(new CancellationTokenSource());
Expand All @@ -131,6 +144,9 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
if (!upResult) {
throw new Error(localize('devContainerAgentHost.invalidUpResult', "Dev Container CLI returned an invalid result: {0}", up.stdout.trim() || up.stderr.trim()));
}
this._containerIds.set(config.workspaceFolder, upResult.containerId);
this._connectionWorkspaces.set(config.connectionId, config.workspaceFolder);
store.add(toDisposable(() => this._connectionWorkspaces.delete(config.connectionId)));

const exec = this._createExec(config.connectionId, config.workspaceFolder, tokenSource.token);
const [{ stdout: unameS }, { stdout: unameM }, { stdout: libc }] = await Promise.all([
Expand Down Expand Up @@ -196,6 +212,9 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
);
this._connections.set(config.connectionId, relay);
store.add(toDisposable(() => this._connections.deleteAndDispose(config.connectionId)));
if (config.resume === true) {
this._suspendedWorkspaces.delete(config.workspaceFolder);
}

return {
connectionId: config.connectionId,
Expand Down Expand Up @@ -393,12 +412,61 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
}

isDockerAvailable(): Promise<boolean> {
this._dockerAvailable ??= this._resolveShellEnvironment()
.then(environment => findExecutable('docker', undefined, undefined, environment))
.then(executable => executable !== undefined);
this._dockerAvailable ??= this._resolveDockerExecutable().then(executable => executable !== undefined);
return this._dockerAvailable;
}

async stopContainer(workspaceFolder: string): Promise<void> {
await this._containerOperations.queue(workspaceFolder, () => this._changeContainerState(workspaceFolder, 'stop'));
}

async removeContainer(workspaceFolder: string): Promise<void> {
await this._containerOperations.queue(workspaceFolder, () => this._changeContainerState(workspaceFolder, 'rm'));
}

private async _changeContainerState(workspaceFolder: string, operation: 'stop' | 'rm'): Promise<void> {
const containerId = this._containerIds.get(workspaceFolder);
if (!containerId) {
return;
}
this._suspendedWorkspaces.add(workspaceFolder);
const connectionIds = [...this._connectionWorkspaces]
.filter(([, workspace]) => workspace === workspaceFolder)
.map(([connectionId]) => connectionId);
await Promise.all(connectionIds.map(connectionId => this.disconnect(connectionId)));
Comment on lines +433 to +436
const args = operation === 'rm' ? ['rm', '--force', containerId] : ['stop', containerId];
const result = await this._runDocker(args);
if (result.code !== 0 && !/No such container/i.test(result.stderr)) {
throw new Error(localize('devContainerAgentHost.containerLifecycleFailed', "Docker failed to {0} Dev Container '{1}' (exit {2}): {3}", operation === 'rm' ? 'remove' : 'stop', containerId, result.code, result.stderr.trim()));
}
if (operation === 'rm' || /No such container/i.test(result.stderr)) {
this._containerIds.delete(workspaceFolder);
}
}

protected async _runDocker(args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> {
const executable = await this._resolveDockerExecutable();
if (!executable) {
throw new Error(localize('devContainerAgentHost.dockerUnavailable', "Docker is not available."));
}
const environment = await this._resolveShellEnvironment();
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { env: environment });
let stdout = '';
let stderr = '';
child.stdout.on('data', data => stdout += data.toString());
child.stderr.on('data', data => stderr += data.toString());
child.once('error', reject);
child.once('close', code => resolve({ stdout, stderr, code: code ?? -1 }));
});
}

private _resolveDockerExecutable(): Promise<string | undefined> {
this._dockerExecutable ??= this._resolveShellEnvironment()
.then(environment => findExecutable('docker', undefined, undefined, environment));
return this._dockerExecutable;
}

protected _spawnDevContainer(
args: readonly string[],
environment: NodeJS.ProcessEnv,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ class TestRelay implements IDevContainerRelay {
class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainService {
readonly relay = new TestRelay();
readonly execCommands: string[] = [];
readonly dockerCommands: string[][] = [];
relayCommand: string | undefined;
failDevContainerUp = false;

constructor(
private readonly _libc = '',
Expand Down Expand Up @@ -66,8 +68,16 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ
return this._resolveShellEnvironment();
}

protected override _runDocker(args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> {
this.dockerCommands.push([...args]);
return Promise.resolve({ stdout: `${args.at(-1)}\n`, stderr: '', code: 0 });
}

protected override _runDevContainer(connectionId: string, args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> {
assert.deepStrictEqual(args, ['up', '--workspace-folder', '/workspace']);
if (this.failDevContainerUp) {
return Promise.reject(new Error('devcontainer up failed'));
}
this._reportOutput(connectionId, 'Starting Dev Container\n');
return Promise.resolve({
stdout: '[1 ms] Starting...\n{"outcome":"success","containerId":"container-id","remoteWorkspaceFolder":"/workspaces/project"}\n',
Expand Down Expand Up @@ -194,6 +204,63 @@ suite('Dev Container Agent Host Main Service', () => {
});
});

test('stops and removes the container after disconnecting its relay', async () => {
const service = store.add(new TestDevContainerAgentHostMainService());
await service.connect({
connectionId: 'connection',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
});

await service.stopContainer('/workspace');
await assert.rejects(service.connect({
connectionId: 'automatic-reconnect',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
}), /is stopped/);
await service.connect({
connectionId: 'explicit-resume',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
resume: true,
});
await service.removeContainer('/workspace');

assert.deepStrictEqual({
relayDisposed: service.relay.disposed,
dockerCommands: service.dockerCommands,
}, {
relayDisposed: true,
dockerCommands: [
['stop', 'container-id'],
['rm', '--force', 'container-id'],
],
});
});

test('keeps automatic reconnects suspended when an explicit resume fails', async () => {
const service = store.add(new TestDevContainerAgentHostMainService());
await service.connect({
connectionId: 'connection',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
});
await service.stopContainer('/workspace');
service.failDevContainerUp = true;

await assert.rejects(service.connect({
connectionId: 'failed-resume',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
resume: true,
}), /devcontainer up failed/);
await assert.rejects(service.connect({
connectionId: 'automatic-reconnect',
workspaceFolder: '/workspace',
name: 'Project Dev Container',
}), /is stopped/);
});

test('installs the Alpine CLI artifact in a musl container', async () => {
const service = store.add(new TestDevContainerAgentHostMainService('musl', true));
await service.connect({
Expand Down
4 changes: 3 additions & 1 deletion src/vs/sessions/common/devContainerAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export interface IDevContainerAgentHostConnection {
export interface IDevContainerAgentHostConnector {
/** Whether the workspace has a supported configuration and Docker is available. */
isAvailable(workspaceUri: URI): Promise<boolean>;
createConnection(workspaceUri: URI, address: string, token: CancellationToken): Promise<IDevContainerAgentHostConnection>;
createConnection(workspaceUri: URI, address: string, token: CancellationToken, options?: { readonly resume: boolean }): Promise<IDevContainerAgentHostConnection>;
stopContainer?(workspaceUri: URI): Promise<void>;
removeContainer?(workspaceUri: URI): Promise<void>;
}

/** Sessions provider and workspace selected after connecting a Dev Container. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2678,7 +2678,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
return this._newSessions.get(sessionId);
}

private _getBackendSessionUri(sessionId: string): URI | undefined {
protected _getBackendSessionUri(sessionId: string): URI | undefined {
const rawId = this._rawIdFromChatId(sessionId);
if (!rawId) {
return undefined;
Expand Down Expand Up @@ -4294,9 +4294,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
if (!cached || !rawId || !connection) {
return false;
}
this._setSessionArchivedLocally(sessionId, isArchived);
connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionIsArchivedChanged as const, isArchived });
return true;
}

protected _setSessionArchivedLocally(sessionId: string, isArchived: boolean): boolean {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (!cached) {
return false;
}
cached.isArchived.set(isArchived, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionIsArchivedChanged as const, isArchived });
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ The service stages a runtime-only `DevContainer` entry and asks the remote Agent

When both worktree isolation and Dev Container execution are selected, the local Agent Host creates the worktree before the container starts. The connector opens the Dev Container on that host worktree, and the container-backed session uses folder isolation so it does not create a second worktree inside the container. The remote session stores only an opaque worktree handle in its metadata; authoritative host paths stay in a local detached-worktree record. Archive, unarchive, and delete resolve that handle through the local Agent Host so cleanup and recreation match ordinary local worktree sessions without retaining a hidden local session. Successful remote listings reconcile their active handles with old local records; cleanup removes only clean worktrees and preserves dirty work.

The dynamic provider remains registered after its sessions become idle, but withdraws its runtime connection and stops the Dev Container once no session is actively working or waiting for input. A later send restarts the container and reconnects the same provider on demand. Archiving first records the remote archived state, then removes the container before removing its mounted worktree. Unarchiving recreates the worktree before starting a replacement container and returns the container to the stopped state when the restored session is still idle.

## Change policy

Update this specification only when connection/provider ownership, routing identity, or the shared Agent Host lifecycle boundary changes. Do not append transport algorithms, telemetry schemas, retry narratives, or incident history.
Loading
Loading