Skip to content

Debug cancelation #23262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 22, 2024
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
28 changes: 25 additions & 3 deletions src/client/testing/common/debugLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { inject, injectable, named } from 'inversify';
import * as path from 'path';
import { DebugConfiguration, l10n, Uri, WorkspaceFolder } from 'vscode';
import { debug, DebugConfiguration, Disposable, l10n, Uri, WorkspaceFolder } from 'vscode';
import { IApplicationShell, IDebugService } from '../../common/application/types';
import { EXTENSION_ROOT_DIR } from '../../common/constants';
import * as internalScripts from '../../common/process/internal/scripts';
Expand All @@ -9,7 +9,7 @@ import { DebuggerTypeName, PythonDebuggerTypeName } from '../../debugger/constan
import { IDebugConfigurationResolver } from '../../debugger/extension/configuration/types';
import { DebugPurpose, LaunchRequestArguments } from '../../debugger/types';
import { IServiceContainer } from '../../ioc/types';
import { traceError } from '../../logging';
import { traceError, traceLog } from '../../logging';
import { TestProvider } from '../types';
import { ITestDebugLauncher, LaunchOptions } from './types';
import { getConfigurationsForWorkspace } from '../../debugger/extension/configuration/launch.json/launchJsonReader';
Expand Down Expand Up @@ -48,7 +48,29 @@ export class DebugLauncher implements ITestDebugLauncher {
);
const debugManager = this.serviceContainer.get<IDebugService>(IDebugService);

debugManager.onDidTerminateDebugSession(() => {
let disposeOfDebugger: Disposable | undefined;
const disposeOfStartDebugging = debugManager.onDidStartDebugSession((session) => {
if (options.token) {
disposeOfDebugger = options?.token.onCancellationRequested(() => {
console.log('Canceling debugger, due to cancelation token called.');
debug.stopDebugging(session);
});
}
});

let disposeTerminateWatcher: Disposable | undefined;
// eslint-disable-next-line prefer-const
disposeTerminateWatcher = debugManager.onDidTerminateDebugSession(() => {
traceLog('Terminating the debugging session and disposing of debugger listeners.');
if (disposeOfDebugger !== undefined) {
disposeOfDebugger.dispose();
}
if (disposeOfStartDebugging !== undefined) {
disposeOfStartDebugging.dispose();
}
if (disposeTerminateWatcher !== undefined) {
disposeTerminateWatcher.dispose();
}
deferred.resolve();
callback?.();
});
Expand Down
2 changes: 1 addition & 1 deletion src/client/testing/testController/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export async function startRunResultNamedPipe(
perConnectionDisposables.push(
// per connection, add a listener for the cancellation token and the data
cancellationToken?.onCancellationRequested(() => {
console.log(`Test Result named pipe ${pipeName} cancelled`);
traceVerbose(`Test Result named pipe ${pipeName} cancelled`);
// if cancel is called on one connection, dispose of all connections
disposeOfServer();
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
};
traceInfo(`Running DEBUG pytest with arguments: ${testArgs} for workspace ${uri.fsPath} \r\n`);
await debugLauncher!.launchDebugger(launchOptions, () => {
traceInfo("Debugger callback called, resolving 'till EOT' deferred for the workspace.");
serverDispose(); // this will resolve deferredTillServerClose
deferredTillEOT?.resolve();
});
Expand Down
6 changes: 6 additions & 0 deletions src/test/testing/common/debugLauncher.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ suite('Unit Tests - Debug Launcher', () => {
return undefined as any;
})
.verifiable(TypeMoq.Times.once());
debugService
.setup((d) => d.onDidStartDebugSession(TypeMoq.It.isAny()))
.returns(() => {
return undefined as any;
})
.verifiable(TypeMoq.Times.once());
}
function createWorkspaceFolder(folderPath: string): WorkspaceFolder {
return {
Expand Down
204 changes: 202 additions & 2 deletions src/test/testing/common/testingAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { TestController, TestRun, Uri } from 'vscode';
import { CancellationTokenSource, DebugSession, TestController, TestRun, Uri, debug } from 'vscode';
import * as typeMoq from 'typemoq';
import * as path from 'path';
import * as assert from 'assert';
import * as fs from 'fs';
import * as sinon from 'sinon';
import { Observable } from 'rxjs';
import * as os from 'os';
import { PytestTestDiscoveryAdapter } from '../../../client/testing/testController/pytest/pytestDiscoveryAdapter';
import { ITestController, ITestResultResolver } from '../../../client/testing/testController/common/types';
import { IPythonExecutionFactory } from '../../../client/common/process/types';
import { IPythonExecutionFactory, IPythonExecutionService, Output } from '../../../client/common/process/types';
import { IConfigurationService, ITestOutputChannel } from '../../../client/common/types';
import { IServiceContainer } from '../../../client/ioc/types';
import { EXTENSION_ROOT_DIR_FOR_TESTS, initialize } from '../../initialize';
Expand All @@ -21,6 +23,9 @@ import { PythonResultResolver } from '../../../client/testing/testController/com
import { TestProvider } from '../../../client/testing/types';
import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants';
import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types';
import { ITestDebugLauncher } from '../../../client/testing/common/types';
import { MockChildProcess } from '../../mocks/mockChildProcess';
import { createDeferred } from '../../../client/common/utils/async';

suite('End to End Tests: test adapters', () => {
let resultResolver: ITestResultResolver;
Expand Down Expand Up @@ -150,6 +155,9 @@ suite('End to End Tests: test adapters', () => {
traceLog('Symlink was not found to remove after tests, exiting successfully, nestedSymlink.');
}
});
teardown(async () => {
sinon.restore();
});
test('unittest discovery adapter small workspace', async () => {
// result resolver and saved data for assertions
let actualData: {
Expand Down Expand Up @@ -1073,4 +1081,196 @@ suite('End to End Tests: test adapters', () => {
assert.strictEqual(failureOccurred, false, failureMsg);
});
});
test('Pytest debug cancelation', async () => {
const debugLauncher = serviceContainer.get<ITestDebugLauncher>(ITestDebugLauncher);
const stopDebuggingStub = sinon.stub(debug, 'stopDebugging');
let calledStopDebugging = false;
stopDebuggingStub.callsFake(() => {
calledStopDebugging = true;
return Promise.resolve();
});

// // mock exec service and exec factory, not very necessary for this test
const execServiceStub = typeMoq.Mock.ofType<IPythonExecutionService>();
const execFactoryStub = typeMoq.Mock.ofType<IPythonExecutionFactory>();
const cancellationTokenSource = new CancellationTokenSource();
let mockProc: MockChildProcess;
execServiceStub
.setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny()))
.returns(() => ({
proc: mockProc,
out: typeMoq.Mock.ofType<Observable<Output<string>>>().object,
dispose: () => {
/* no-body */
},
}));
execFactoryStub
.setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny()))
.returns(() => Promise.resolve(execServiceStub.object));
execFactoryStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined);
execServiceStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined);

resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri);

const testId = `${rootPathErrorWorkspace}/test_seg_fault.py::TestSegmentationFault::test_segfault`;
const testIds: string[] = [testId];

// set workspace to test workspace folder
workspaceUri = Uri.parse(rootPathErrorWorkspace);
configService.getSettings(workspaceUri).testing.pytestArgs = [];

const debugSessionStub = typeMoq.Mock.ofType<DebugSession>();
sinon.stub(debug, 'onDidStartDebugSession').callsFake((cb) => {
// run the callback right away to add the cancelation token listener
cb(debugSessionStub.object);
return {
dispose: () => {
/* no-body */
},
};
});
const awaitStopDebugging = createDeferred();

sinon.stub(debug, 'onDidTerminateDebugSession').callsFake((cb) => {
// wait for the stop debugging to be called before resolving the promise
// the terminate debug session does cleanup
awaitStopDebugging.promise.then(() => {
cb(debugSessionStub.object);
});
return {
dispose: () => {
// void
},
};
});
// handle cancelation token from debugger
sinon.stub(debug, 'startDebugging').callsFake((folder, nameOrConfiguration, _parentSession) => {
// check to make sure start debugging is called correctly
if (typeof nameOrConfiguration !== 'string') {
assert.strictEqual(nameOrConfiguration.type, 'debugpy', 'Expected debugpy');
} else {
assert.fail('Expected nameOrConfiguration to be an object');
}
assert.ok(folder, 'Expected folder to be defined');
assert.strictEqual(folder.name, 'test', 'Expected folder name to be test');
// cancel the token and trigger the stop debugging callback
awaitStopDebugging.resolve();
cancellationTokenSource.cancel();
return Promise.resolve(true);
});

// run pytest execution
const executionAdapter = new PytestTestExecutionAdapter(
configService,
testOutputChannel.object,
resultResolver,
envVarsService,
);

const testRun = typeMoq.Mock.ofType<TestRun>();
testRun.setup((t) => t.token).returns(() => cancellationTokenSource.token);

await executionAdapter
.runTests(workspaceUri, testIds, true, testRun.object, pythonExecFactory, debugLauncher)
.finally(() => {
// verify that the stop debugging was called
assert.ok(calledStopDebugging, 'Expected stopDebugging to be called');
});
});
test('UNITTEST debug cancelation', async () => {
const debugLauncher = serviceContainer.get<ITestDebugLauncher>(ITestDebugLauncher);
const stopDebuggingStub = sinon.stub(debug, 'stopDebugging');
let calledStopDebugging = false;
stopDebuggingStub.callsFake(() => {
calledStopDebugging = true;
return Promise.resolve();
});

// // mock exec service and exec factory, not very necessary for this test
const execServiceStub = typeMoq.Mock.ofType<IPythonExecutionService>();
const execFactoryStub = typeMoq.Mock.ofType<IPythonExecutionFactory>();
const cancellationTokenSource = new CancellationTokenSource();
let mockProc: MockChildProcess;
execServiceStub
.setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny()))
.returns(() => ({
proc: mockProc,
out: typeMoq.Mock.ofType<Observable<Output<string>>>().object,
dispose: () => {
/* no-body */
},
}));
execFactoryStub
.setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny()))
.returns(() => Promise.resolve(execServiceStub.object));
execFactoryStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined);
execServiceStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined);

resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri);

const testId = `${rootPathErrorWorkspace}/test_seg_fault.py::TestSegmentationFault::test_segfault`;
const testIds: string[] = [testId];

// set workspace to test workspace folder
workspaceUri = Uri.parse(rootPathErrorWorkspace);
configService.getSettings(workspaceUri).testing.pytestArgs = [];

const debugSessionStub = typeMoq.Mock.ofType<DebugSession>();
sinon.stub(debug, 'onDidStartDebugSession').callsFake((cb) => {
// run the callback right away to add the cancelation token listener
cb(debugSessionStub.object);
return {
dispose: () => {
/* no-body */
},
};
});
const awaitStopDebugging = createDeferred();

sinon.stub(debug, 'onDidTerminateDebugSession').callsFake((cb) => {
// wait for the stop debugging to be called before resolving the promise
// the terminate debug session does cleanup
awaitStopDebugging.promise.then(() => {
cb(debugSessionStub.object);
});
return {
dispose: () => {
// void
},
};
});
// handle cancelation token from debugger
sinon.stub(debug, 'startDebugging').callsFake((folder, nameOrConfiguration, _parentSession) => {
// check to make sure start debugging is called correctly
if (typeof nameOrConfiguration !== 'string') {
assert.strictEqual(nameOrConfiguration.type, 'debugpy', 'Expected debugpy');
} else {
assert.fail('Expected nameOrConfiguration to be an object');
}
assert.ok(folder, 'Expected folder to be defined');
assert.strictEqual(folder.name, 'test', 'Expected folder name to be test');
// cancel the token and trigger the stop debugging callback
awaitStopDebugging.resolve();
cancellationTokenSource.cancel();
return Promise.resolve(true);
});

// run pytest execution
const executionAdapter = new UnittestTestExecutionAdapter(
configService,
testOutputChannel.object,
resultResolver,
envVarsService,
);

const testRun = typeMoq.Mock.ofType<TestRun>();
testRun.setup((t) => t.token).returns(() => cancellationTokenSource.token);

await executionAdapter
.runTests(workspaceUri, testIds, true, testRun.object, pythonExecFactory, debugLauncher)
.finally(() => {
// verify that the stop debugging was called
assert.ok(calledStopDebugging, 'Expected stopDebugging to be called');
});
});
});
Loading