From a98d01cbc174d046cd4cdcfe145830413b8b01f6 Mon Sep 17 00:00:00 2001 From: Eugene Kornykhin Date: Fri, 24 Apr 2026 22:46:34 +0300 Subject: [PATCH 1/2] fix absence of pytest kill in Cancel Test Run --- .../testing/testController/pytest/pytestExecutionAdapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index 102841c2e2dd..33a4e5f36ce9 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -246,6 +246,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { }); const result = execService?.execObservable(runArgs, spawnOptions); + resultProc = result?.proc; // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. From 30c394891d29d346005a7afaea84030e80a1de0c Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:58:37 -0700 Subject: [PATCH 2/2] Handle pytest cancellation before subprocess assignment Re-check the cancellation token after capturing the spawned process so cancellation in the registration-to-assignment window still terminates pytest. Add deterministic regression coverage for that race.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pytest/pytestExecutionAdapter.ts | 17 ++++++++++---- .../pytestExecutionAdapter.unit.test.ts | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index a982037d90e5..31c9a73ac2f8 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -233,13 +233,19 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { traceInfo(`Running pytest with arguments: ${runArgs.join(' ')} for workspace ${uri.fsPath} \r\n`); let resultProc: ChildProcess | undefined; + let processKilled = false; + const killResultProcess = () => { + if (resultProc && !processKilled) { + processKilled = true; + resultProc.kill(); + return true; + } + return false; + }; runInstance.token.onCancellationRequested(() => { traceInfo(`Test run cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); - // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. - if (resultProc) { - resultProc?.kill(); - } else { + if (!killResultProcess()) { deferredTillExecClose.resolve(); serverCancel.cancel(); } @@ -247,6 +253,9 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { const result = execService?.execObservable(runArgs, spawnOptions); resultProc = result?.proc; + if (runInstance.token.isCancellationRequested) { + killResultProcess(); + } // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. diff --git a/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts b/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts index 9b83f1bbd088..99e1eaf9f939 100644 --- a/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts @@ -38,8 +38,10 @@ suite('pytest test execution adapter', () => { let mockProc: MockChildProcess; let utilsWriteTestIdsFileStub: sinon.SinonStub; let utilsStartRunResultNamedPipeStub: sinon.SinonStub; + let onExecObservable: (() => void) | undefined; setup(() => { + onExecObservable = undefined; useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); configService = ({ @@ -59,6 +61,7 @@ suite('pytest test execution adapter', () => { execService .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { + onExecObservable?.(); deferred4.resolve(); return { proc: mockProc as any, @@ -204,6 +207,25 @@ suite('pytest test execution adapter', () => { await execution; cancellationToken.dispose(); }); + test('cancelling before the pytest subprocess is captured kills it once available', async () => { + utilsWriteTestIdsFileStub.resolves('testIdPipe-mockName'); + utilsStartRunResultNamedPipeStub.callsFake((_callback, deferredTillServerClose, token) => { + token?.onCancellationRequested(() => deferredTillServerClose.resolve()); + return Promise.resolve('runResultPipe-mockName'); + }); + const cancellationToken = new CancellationTokenSource(); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => cancellationToken.token); + const killStub = sinon.stub(mockProc, 'kill'); + onExecObservable = () => cancellationToken.cancel(); + const uri = Uri.file(myTestPath); + adapter = new PytestTestExecutionAdapter(configService); + + await adapter.runTests(uri, [], TestRunProfileKind.Run, testRun.object, execFactory.object); + + sinon.assert.calledOnce(killStub); + cancellationToken.dispose(); + }); test('pytest execution respects settings.testing.cwd when present', async () => { const deferred2 = createDeferred(); const deferred3 = createDeferred();