Skip to content
Merged
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
20 changes: 20 additions & 0 deletions resources/python/launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.md in the project root for license information.

# This acts as a simple launcher for debugpy that only redirects the args to the actual launcher inside the container
import os, sys

# Container id is the last arg
containerId = sys.argv[-1]
args = sys.argv[1:-1]

# If the adapterHost is only a port number, then append the default DNS name 'host.docker.internal'
adapterHost = args[0]

if adapterHost.isnumeric():
args[0] = 'host.docker.internal:' + adapterHost

dockerExecArgs = ['docker', 'exec', '-d', containerId, 'python', '/pydbg/debugpy/launcher'] + args

print(' '.join(dockerExecArgs))
os.execvp('docker', dockerExecArgs)
2 changes: 1 addition & 1 deletion src/configureWorkspace/configurePython.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { dockerDebugScaffoldingProvider, PythonScaffoldingOptions } from '../deb
import { ext } from "../extensionVariables";
import { localize } from '../localize';
import { getValidImageName } from '../utils/getValidImageName';
import { getPythonProjectType, PythonDefaultPorts, PythonFileExtension, PythonFileTarget, PythonModuleTarget, PythonProjectType, PythonTarget, inferPythonArgs } from "../utils/pythonUtils";
import { getPythonProjectType, inferPythonArgs, PythonDefaultPorts, PythonFileExtension, PythonFileTarget, PythonModuleTarget, PythonProjectType, PythonTarget } from "../utils/pythonUtils";
import { getComposePorts, getExposeStatements } from './configure';
import { ConfigureTelemetryProperties, genCommonDockerIgnoreFile, quickPickGenerateComposeFiles } from './configUtils';
import { ScaffolderContext, ScaffoldFile } from './scaffolding';
Expand Down
13 changes: 3 additions & 10 deletions src/debugging/DockerDebugConfigurationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,9 @@ export class DockerDebugConfigurationProvider implements DebugConfigurationProvi
if (resolvedConfiguration.dockerOptions
&& (resolvedConfiguration.dockerOptions.removeContainerAfterDebug === undefined || resolvedConfiguration.dockerOptions.removeContainerAfterDebug)
&& resolvedConfiguration.dockerOptions.containerName) {

// Since Python is a special case as we handle waiting for the debugger to be ready while resolving
// the launch configuration, and since this method comes later then we shouldn't remove a container
// that we just created.
// TODO: this needs to be removed as soon as the Python extension adds a way to retry while connecting to a remote debugger.
if (resolvedConfiguration.type !== 'python') {
try {
await this.dockerClient.removeContainer(resolvedConfiguration.dockerOptions.containerName, { force: true });
} catch { }
}
try {
await this.dockerClient.removeContainer(resolvedConfiguration.dockerOptions.containerName, { force: true });
} catch { }

// Now register the container for removal after the debug session ends
const disposable = debug.onDidTerminateDebugSession(async session => {
Expand Down
122 changes: 40 additions & 82 deletions src/debugging/python/PythonDebugHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
* Licensed under the MIT License. See LICENSE.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as fse from 'fs-extra';
import * as os from 'os';
import * as vscode from 'vscode';
import { localize } from '../../localize';
import { PythonExtensionHelper } from '../../tasks/python/PythonExtensionHelper';
import { PythonDefaultDebugPort, PythonProjectType } from '../../utils/pythonUtils';
import * as path from 'path';
import { ext } from '../../extensionVariables';
import { PythonRunTaskDefinition } from '../../tasks/python/PythonTaskHelper';
import LocalOSProvider from '../../utils/LocalOSProvider';
import { PythonProjectType } from '../../utils/pythonUtils';
import ChildProcessProvider from '../coreclr/ChildProcessProvider';
import CliDockerClient from '../coreclr/CliDockerClient';
import { DebugHelper, DockerDebugContext, DockerDebugScaffoldContext, inferContainerName, ResolvedDebugConfiguration, resolveDockerServerReadyAction } from '../DebugHelper';
Expand Down Expand Up @@ -36,10 +35,6 @@ export interface PythonDockerDebugConfiguration extends DockerDebugConfiguration
}

export class PythonDebugHelper implements DebugHelper {
public constructor(
private readonly cliDockerClient: CliDockerClient) {
}

public async provideDebugConfigurations(context: DockerDebugScaffoldContext, options?: PythonScaffoldingOptions): Promise<DockerDebugConfiguration[]> {
// Capitalize the first letter.
const projectType = options.projectType.charAt(0).toUpperCase() + options.projectType.slice(1);
Expand All @@ -64,45 +59,8 @@ export class PythonDebugHelper implements DebugHelper {

public async resolveDebugConfiguration(context: DockerDebugContext, debugConfiguration: PythonDockerDebugConfiguration): Promise<ResolvedDebugConfiguration | undefined> {
const containerName = inferContainerName(debugConfiguration, context, context.folder.name);

// Since Python is a special case, we need to ensure the container is removed before attempting to resolve
// the debug configuration.
try {
await this.cliDockerClient.removeContainer(containerName, { force: true });
} catch { }

const debuggerLogFilePath = await PythonExtensionHelper.getDebuggerLogFilePath(context.folder.name);
await fse.remove(debuggerLogFilePath);

let debuggerReadyPromise = Promise.resolve();
if (debugConfiguration.preLaunchTask) {
// There is this limitation with the Python debugger where we need to ensure it's ready before allowing VSCode to attach,
// if attach happens too soon then it will fail silently. The workaround here is to set the preLaunchTask to undefined,
// then execute it ourselves with a listener to when it is finished, then wait for the debugger to be ready and return
// the resolved launch configuration.

const task = await this.tryGetPreLaunchTask(debugConfiguration.preLaunchTask);

if (!task) {
throw new Error(localize('vscode-docker.debug.python.noPreLaunch', 'Unable to find the prelaunch task with the name: {0}', debugConfiguration.preLaunchTask));
}

debugConfiguration.preLaunchTask = undefined;
context.actionContext.errorHandling.suppressReportIssue = true;

debuggerReadyPromise = PythonExtensionHelper.ensureDebuggerReady(task, debuggerLogFilePath, containerName, this.cliDockerClient);

/* eslint-disable-next-line @typescript-eslint/no-floating-promises */
vscode.tasks.executeTask(task);
}

return await debuggerReadyPromise.then(() => {
return this.resolveDebugConfigurationInternal(debugConfiguration, containerName, context);
});
}

private resolveDebugConfigurationInternal(debugConfiguration: PythonDockerDebugConfiguration, containerName: string, context: DockerDebugContext): ResolvedDebugConfiguration {
const projectType = debugConfiguration.python.projectType;
const pythonRunTaskOptions = (context.runDefinition as PythonRunTaskDefinition).python;

const dockerServerReadyAction =
resolveDockerServerReadyAction(
Expand All @@ -114,21 +72,14 @@ export class PythonDebugHelper implements DebugHelper {
},
true);

// These properties are required by the old debugger, should be changed to normal properties in the configuration
// as soon as the new debugger is released to 100% of the users.
const debugOptions = ['FixFilePathCase', 'RedirectOutput', 'ShowReturnValue'];

if (os.platform() === 'win32') {
debugOptions.push('WindowsClient');
}
const args = [...debugConfiguration.args || pythonRunTaskOptions.args || [], containerName];
const launcherPath = path.join(ext.context.asAbsolutePath('resources'), 'python', 'launcher.py');

return {
...debugConfiguration,
name: debugConfiguration.name,
preLaunchTask: debugConfiguration.preLaunchTask,
type: 'python',
request: 'attach',
workspaceFolder: context.folder.uri.fsPath,
host: debugConfiguration.python.host || 'localhost',
port: debugConfiguration.python.port || PythonDefaultDebugPort,
request: 'launch',
pathMappings: debugConfiguration.python.pathMappings,
justMyCode: debugConfiguration.python.justMyCode || true,
django: debugConfiguration.python.django || projectType === 'django',
Expand All @@ -138,28 +89,18 @@ export class PythonDebugHelper implements DebugHelper {
dockerServerReadyAction: dockerServerReadyAction,
removeContainerAfterDebug: debugConfiguration.removeContainerAfterDebug
},
debugOptions: debugOptions
debugLauncherPath: debugConfiguration.debugLauncherPath || launcherPath,
debugAdapterHost: debugConfiguration.debugAdapterHost || await this.getDebugAdapterHost(),
console: debugConfiguration.console || "integratedTerminal",
internalConsoleOptions: debugConfiguration.internalConsoleOptions || "openOnSessionStart",
module: debugConfiguration.module || pythonRunTaskOptions.module,
program: debugConfiguration.file || pythonRunTaskOptions.file,
redirectOutput: debugConfiguration.redirectOutput || true,
args: args,
cwd: '.'
};
}

private async tryGetPreLaunchTask(prelaunchTaskName: string): Promise<vscode.Task> | undefined {
if (!prelaunchTaskName) {
return undefined;
}

const tasks = await vscode.tasks.fetchTasks();

if (tasks) {
const results = tasks.filter(t => t.name.localeCompare(prelaunchTaskName) === 0);

if (results.length > 0) {
return results[0];
}
}

return undefined;
}

private getServerReadyPattern(projectType: PythonProjectType): string | undefined {
switch (projectType) {
case 'django':
Expand All @@ -170,8 +111,25 @@ export class PythonDebugHelper implements DebugHelper {
return undefined;
}
}
}

const dockerClient = new CliDockerClient(new ChildProcessProvider());
private async getDebugAdapterHost(): Promise<string> {
const osProvider = new LocalOSProvider();

// For Windows and Mac, we ask debugpy to listen on localhost:{randomPort} and then
// we use 'host.docker.internal' in the launcher to get the host's ip address.
if (osProvider.os != 'Linux') {
return 'localhost';
}

// For Linux, 'host.docker.internal' doesn't work, so we ask debugpy to listen
// on the bridge network's ip address (predefined network).
const dockerClient = new CliDockerClient(new ChildProcessProvider());
const dockerBridgeIp = await dockerClient.inspectObject('bridge', {
format: '{{(index .IPAM.Config 0).Gateway}}'
});

return dockerBridgeIp;
}
}

export const pythonDebugHelper = new PythonDebugHelper(dockerClient);
export const pythonDebugHelper = new PythonDebugHelper();
120 changes: 14 additions & 106 deletions src/tasks/python/PythonExtensionHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@

import * as fse from 'fs-extra';
import * as path from 'path';
import * as semver from 'semver';
import * as vscode from "vscode";
import CliDockerClient from '../../debugging/coreclr/CliDockerClient';
import { localize } from '../../localize';
import { delay } from '../../utils/promiseUtils';
import { getTempDirectoryPath, PythonDefaultDebugPort, PythonTarget } from '../../utils/pythonUtils';
import { dockerTaskEndEventListener, DockerTaskEvent } from '../DockerTaskEndEventListener';

export namespace PythonExtensionHelper {
export interface DebugLaunchOptions {
Expand All @@ -21,100 +18,10 @@ export namespace PythonExtensionHelper {
wait?: boolean;
}

export function getDebuggerEnvironmentVars(): { [key: string]: string } {
return { 'PTVSD_LOG_DIR': '/dbglogs' };
}

export async function getDebuggerLogFilePath(folderName: string): Promise<string> {
// The debugger generates the log file with the name in this format: ptvsd-{pid}.log,
// So given that we run the debugger as the entry point, then the PID is guaranteed to be 1.
const tempDir = await getTempDirectoryPath();
return path.join(tempDir, folderName, 'ptvsd-1.log');
}

export async function ensureDebuggerReady(prelaunchTask: vscode.Task, debuggerSemaphorePath: string, containerName: string, cliDockerClient: CliDockerClient): Promise<void> {
// tslint:disable-next-line:promise-must-complete
return new Promise((resolve, reject) => {
const dockerTaskListener = dockerTaskEndEventListener.event((taskEvent: DockerTaskEvent) => {
if (!taskEvent.success) {
cleanupListeners();
reject(localize('vscode-docker.tasks.pythonExt.failedToAttach', 'Failed to attach the debugger, please see the terminal output for more details.'));
}
});

const listener = vscode.tasks.onDidEndTask(async e => {
if (e.execution.task.name === prelaunchTask.name) {
try {
// There is no way to know the result of the completed task, so a best guess is to check if the container is running.
const containerRunning = await cliDockerClient.inspectObject(containerName, { format: '{{.State.Running}}' });

if (containerRunning === 'false') {
reject(localize('vscode-docker.tasks.pythonExt.failedToAttach', 'Failed to attach the debugger, please see the terminal output for more details.'));
}

const maxRetriesCount = 20;
let retries = 0;
let created = false;

// Look for the magic string below in the log file with a retry every 0.5 second for a maximum of 10 seconds.
// TODO: Should be gone as soon as the retry logic is part of the Python debugger/extension.
while (++retries < maxRetriesCount && !created) {
if (await fse.pathExists(debuggerSemaphorePath)) {
const contents = await fse.readFile(debuggerSemaphorePath);

created = contents.toString().indexOf('Starting server daemon on') >= 0;
if (created) {
break;
}
}

await delay(500);
}

if (created) {
resolve();
} else {
reject(localize('vscode-docker.tasks.pythonExt.attachTimeout', 'Failed to attach the debugger within the alotted timeout.'));
}
} catch {
reject(localize('vscode-docker.tasks.pythonExt.unexpectedAttachError', 'An unexpected error occurred while attempting to attach the debugger.'));
} finally {
cleanupListeners();
}
}
});

const cleanupListeners = () => {
/* eslint-disable no-unused-expressions */
listener?.dispose();
dockerTaskListener?.dispose();
/* eslint-enable no-unused-expressions */
};
});
}

export function getRemotePtvsdCommand(target: PythonTarget, args?: string[], options?: DebugLaunchOptions): string {
let fullTarget: string;

if ('file' in target) {
fullTarget = target.file;
} else if ('module' in target) {
fullTarget = `-m ${target.module}`;
} else {
throw new Error(localize('vscode-docker.tasks.pythonExt.moduleOrFile', 'One of either module or file must be provided.'));
}

options = options ?? {};
options.host = options.host || '0.0.0.0';
options.port = options.port || PythonDefaultDebugPort;
options.wait = !!options.wait;
args = args ?? [];

return `/pydbg/ptvsd --host ${options.host} --port ${options.port} ${options.wait ? '--wait' : ''} ${fullTarget} ${args.join(' ')}`;
}

export async function getLauncherFolderPath(): Promise<string> {
const pyExtensionId = 'ms-python.python';
const minPyExtensionVersion = new semver.SemVer('2020.5.78807');

const pyExt = vscode.extensions.getExtension(pyExtensionId);
const button = localize('vscode-docker.tasks.pythonExt.openExtension', 'Open Extension');

Expand All @@ -128,18 +35,19 @@ export namespace PythonExtensionHelper {
return undefined;
}

const debuggerPath = path.join(pyExt.extensionPath, 'pythonFiles', 'lib', 'python');
const oldDebugger = path.join(debuggerPath, 'old_ptvsd');
const newDebugger = path.join(debuggerPath, 'new_ptvsd');
const version = new semver.SemVer(pyExt.packageJSON.version);

if (version.compare(minPyExtensionVersion) < 0) {
await vscode.window.showErrorMessage(localize('vscode-docker.tasks.pythonExt.pythonExtensionNotSupported', 'The installed Python extension does not meet the minimum requirements, please update to the latest version and try again.'));
return undefined;
}

await pyExt.activate();

// Always favor the old_ptvsd debugger since it will work in all cases.
// If it is not found, then look for the new instead.
// TODO: This should be revisited when the Python extension releases the new debugger since it might have a different name.
const debuggerPath = path.join(pyExt.extensionPath, 'pythonFiles', 'lib', 'python', 'debugpy', 'no_wheels');

if ((await fse.pathExists(oldDebugger))) {
return oldDebugger;
} else if ((await fse.pathExists(newDebugger))) {
return newDebugger;
if ((await fse.pathExists(debuggerPath))) {
return debuggerPath;
}

throw new Error(localize('vscode-docker.tasks.pythonExt.noDebugger', 'Unable to find the debugger in the Python extension.'));
Expand Down
Loading