Skip to content

Commit 2bebf16

Browse files
authored
Fix Databricks Connect run/debug interpreter split-brain (#1912)
## Why - **"Debug with Databricks Connect" used the wrong interpreter.** It started a `debugpy` session **without a pinned interpreter**, so debugpy fell back to the Python extension's folder-level selection — which can differ from the environment used by "run" and by the verification checks. With e.g. a system Python 3.9 selected there, debug crashed with a raw `ModuleNotFoundError: No module named 'databricks'` while run worked fine. - **Stale interpreter checks.** The cached environment state can refer to a previously selected interpreter, because the Python extension doesn't always notify us when the interpreter changes — so a mismatch surfaced as a bootstrap traceback instead of the actionable setup flow. ## What - Pin debugpy to the verified interpreter via the debug configuration's `python` attribute (`DatabricksPythonDebugConfiguration.python`). - Resolve the interpreter once in a shared run/debug preflight (`resolveDbconnectLaunch`), so run and debug provably use the **same** executable (removes the duplicated preamble). - Re-verify the environment before launch when the selected interpreter changed since the cached check — only while `CONNECTED`, since a re-check blocks on the connection. - After an in-flow environment setup, re-check and let the launch **proceed** instead of aborting and forcing the user to re-trigger. ## Verification - New/updated unit tests in `RunCommands.test.ts`: debug-config pinning, stale-interpreter re-verification, no-reverify-while-disconnected, and proceed-after-setup. RunCommands suite passes (6 tests) in the VS Code test host. - `eslint` and `prettier` clean on the changed files. ## Notes This is the first of three slices carved out of #1905 (run/debug interpreter split-brain). The pip-less-venv and Python-version-requirement fixes follow in separate PRs. This pull request and its description were written by Isaac.
1 parent 1fd6dea commit 2bebf16

3 files changed

Lines changed: 251 additions & 23 deletions

File tree

packages/databricks-vscode/src/run/DatabricksDebugConfigurationProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface DatabricksPythonDebugConfiguration extends DebugConfiguration {
1313
env?: Record<string, any>;
1414
isInternalDatabricksRun?: boolean;
1515
console?: "integratedTerminal" | "externalTerminal" | "internalConsole";
16+
/** Path to the python interpreter debugpy should use */
17+
python?: string;
1618
}
1719

1820
function isTest(debugConfiguration: DebugConfiguration) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as assert from "assert";
2+
import {commands, ExtensionContext, Uri} from "vscode";
3+
import {anything, instance, mock, verify, when} from "ts-mockito";
4+
import {RunCommands} from "./RunCommands";
5+
import {ConnectionManager} from "../configuration/ConnectionManager";
6+
import {MsPythonExtensionWrapper} from "../language/MsPythonExtensionWrapper";
7+
import {FeatureManager, FeatureState} from "../feature-manager/FeatureManager";
8+
import {CustomWhenContext} from "../vscode-objs/CustomWhenContext";
9+
import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager";
10+
import {Telemetry} from "../telemetry";
11+
12+
function featureState(available: boolean, executable?: string): FeatureState {
13+
return {
14+
available,
15+
steps: new Map([
16+
[
17+
"checkPythonEnvironment",
18+
{
19+
id: "checkPythonEnvironment",
20+
available,
21+
title: "Active Environment: .venv",
22+
message: executable,
23+
},
24+
],
25+
]),
26+
};
27+
}
28+
29+
describe(__filename, () => {
30+
let featureManagerMock: FeatureManager;
31+
let pythonExtensionMock: MsPythonExtensionWrapper;
32+
let connectionManagerMock: ConnectionManager;
33+
let runCommands: RunCommands;
34+
35+
beforeEach(() => {
36+
featureManagerMock = mock<FeatureManager>(FeatureManager);
37+
pythonExtensionMock = mock(MsPythonExtensionWrapper);
38+
connectionManagerMock = mock(ConnectionManager);
39+
when(connectionManagerMock.state).thenReturn("CONNECTED");
40+
runCommands = new RunCommands(
41+
instance(connectionManagerMock),
42+
instance(mock(WorkspaceFolderManager)),
43+
instance(pythonExtensionMock),
44+
instance(featureManagerMock),
45+
{subscriptions: []} as unknown as ExtensionContext,
46+
instance(mock(CustomWhenContext)),
47+
instance(mock(Telemetry))
48+
);
49+
});
50+
51+
describe("createDbconnectDebugConfig", () => {
52+
it("should pin debugpy to the verified python executable", () => {
53+
const config = runCommands.createDbconnectDebugConfig(
54+
Uri.file("/project/src/main.py"),
55+
"/project/.venv/bin/python"
56+
);
57+
assert.strictEqual(config.type, "debugpy");
58+
assert.strictEqual(config.databricks, true);
59+
assert.strictEqual(
60+
config.program,
61+
Uri.file("/project/src/main.py").fsPath
62+
);
63+
assert.strictEqual(config.python, "/project/.venv/bin/python");
64+
});
65+
});
66+
67+
describe("checkDbconnectEnabled", () => {
68+
it("should pass when the verified interpreter is still selected", async () => {
69+
when(
70+
featureManagerMock.isEnabled("environment.dependencies")
71+
).thenResolve(featureState(true, "/project/.venv/bin/python"));
72+
when(pythonExtensionMock.getPythonExecutable()).thenResolve(
73+
"/project/.venv/bin/python"
74+
);
75+
76+
const result = await runCommands["checkDbconnectEnabled"]();
77+
78+
assert.strictEqual(result, true);
79+
verify(
80+
featureManagerMock.isEnabled("environment.dependencies", true)
81+
).never();
82+
});
83+
84+
it("should re-verify when the selected interpreter changed", async () => {
85+
when(
86+
featureManagerMock.isEnabled("environment.dependencies")
87+
).thenResolve(featureState(true, "/old/python"));
88+
when(
89+
featureManagerMock.isEnabled("environment.dependencies", true)
90+
).thenResolve(featureState(true, "/project/.venv/bin/python"));
91+
when(pythonExtensionMock.getPythonExecutable()).thenResolve(
92+
"/project/.venv/bin/python"
93+
);
94+
95+
const result = await runCommands["checkDbconnectEnabled"]();
96+
97+
assert.strictEqual(result, true);
98+
verify(
99+
featureManagerMock.isEnabled("environment.dependencies", true)
100+
).once();
101+
});
102+
103+
it("should not re-verify while disconnected", async () => {
104+
when(connectionManagerMock.state).thenReturn("DISCONNECTED");
105+
when(
106+
featureManagerMock.isEnabled("environment.dependencies")
107+
).thenResolve(featureState(true, "/old/python"));
108+
when(pythonExtensionMock.getPythonExecutable()).thenResolve(
109+
"/project/.venv/bin/python"
110+
);
111+
112+
const result = await runCommands["checkDbconnectEnabled"]();
113+
114+
assert.strictEqual(result, true);
115+
verify(
116+
featureManagerMock.isEnabled("environment.dependencies", true)
117+
).never();
118+
});
119+
120+
it("should not re-verify when the feature is unavailable", async () => {
121+
when(featureManagerMock.isEnabled(anything())).thenResolve(
122+
featureState(false, undefined)
123+
);
124+
when(pythonExtensionMock.getPythonExecutable()).thenResolve(
125+
undefined
126+
);
127+
// Stub the setup command flow that runs for unavailable features.
128+
const registration = commands.registerCommand(
129+
"databricks.environment.setup",
130+
() => false
131+
);
132+
try {
133+
const result = await runCommands["checkDbconnectEnabled"]();
134+
assert.strictEqual(result, false);
135+
verify(
136+
featureManagerMock.isEnabled(
137+
"environment.dependencies",
138+
true
139+
)
140+
).never();
141+
} finally {
142+
registration.dispose();
143+
}
144+
});
145+
146+
it("should proceed after a successful in-flow setup", async () => {
147+
// Unavailable on the first check; the setup flow fixes it, so the
148+
// re-check after setup reports available and the launch proceeds.
149+
when(
150+
featureManagerMock.isEnabled("environment.dependencies")
151+
).thenResolve(
152+
featureState(false, undefined),
153+
featureState(true, "/project/.venv/bin/python")
154+
);
155+
const registration = commands.registerCommand(
156+
"databricks.environment.setup",
157+
() => undefined
158+
);
159+
try {
160+
const result = await runCommands["checkDbconnectEnabled"]();
161+
assert.strictEqual(result, true);
162+
} finally {
163+
registration.dispose();
164+
}
165+
});
166+
});
167+
});

packages/databricks-vscode/src/run/RunCommands.ts

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {LocalUri} from "../sync/SyncDestination";
55
import {DatabricksPythonDebugConfiguration} from "./DatabricksDebugConfigurationProvider";
66
import {MsPythonExtensionWrapper} from "../language/MsPythonExtensionWrapper";
77
import path from "path";
8-
import {FeatureManager} from "../feature-manager/FeatureManager";
8+
import {FeatureManager, FeatureState} from "../feature-manager/FeatureManager";
99
import {
1010
escapeExecutableForTerminal,
1111
escapePathArgument,
@@ -113,13 +113,46 @@ export class RunCommands {
113113
}
114114

115115
private async checkDbconnectEnabled() {
116-
const featureState = await this.featureManager.isEnabled(
116+
let featureState = await this.featureManager.isEnabled(
117117
"environment.dependencies"
118118
);
119+
if (
120+
featureState.available &&
121+
// Re-checks block on an established connection, so don't force
122+
// one while disconnected.
123+
this.connection.state === "CONNECTED" &&
124+
(await this.isPythonEnvironmentStale(featureState))
125+
) {
126+
// The Python extension doesn't always notify us about interpreter
127+
// changes, so the cached state can refer to a previously selected
128+
// interpreter. Re-check before launching anything with it.
129+
featureState = await this.featureManager.isEnabled(
130+
"environment.dependencies",
131+
true
132+
);
133+
}
119134
if (featureState.available) {
120135
return true;
121136
}
122-
return await commands.executeCommand("databricks.environment.setup");
137+
// Run the setup flow, then re-check: a successful setup should let the
138+
// launch proceed instead of aborting and making the user re-trigger.
139+
await commands.executeCommand("databricks.environment.setup");
140+
return (await this.featureManager.isEnabled("environment.dependencies"))
141+
.available;
142+
}
143+
144+
private async isPythonEnvironmentStale(featureState: FeatureState) {
145+
// For an accepted checkPythonEnvironment step the message holds
146+
// the path of the verified python executable.
147+
const verifiedExecutable = featureState.steps.get(
148+
"checkPythonEnvironment"
149+
)?.message;
150+
const currentExecutable =
151+
await this.pythonExtension.getPythonExecutable();
152+
return (
153+
verifiedExecutable !== undefined &&
154+
verifiedExecutable !== currentExecutable
155+
);
123156
}
124157

125158
private getTargetResource(resource?: Uri): Uri | undefined {
@@ -129,27 +162,63 @@ export class RunCommands {
129162
return resource;
130163
}
131164

132-
async debugFileUsingDbconnect(resource?: Uri) {
165+
/**
166+
* Shared preflight for the run and debug Databricks Connect launchers:
167+
* verifies the environment, resolves the target file, and resolves the
168+
* python interpreter. Run and debug must use the *same* interpreter, so it
169+
* is resolved here in one place. Returns undefined (after surfacing an
170+
* error message) when any step fails.
171+
*/
172+
private async resolveDbconnectLaunch(
173+
resource?: Uri
174+
): Promise<{targetResource: Uri; executable: string} | undefined> {
133175
if (!(await this.checkDbconnectEnabled())) {
134-
return;
176+
return undefined;
135177
}
136-
137178
const targetResource = this.getTargetResource(resource);
138179
if (!targetResource) {
139-
window.showErrorMessage("Open a file to run");
140-
return;
180+
window.showErrorMessage("Open a file to run on Databricks");
181+
return undefined;
141182
}
142-
const config: DatabricksPythonDebugConfiguration = {
183+
const executable = await this.pythonExtension.getPythonExecutable();
184+
if (!executable) {
185+
window.showErrorMessage("No python executable found");
186+
return undefined;
187+
}
188+
return {targetResource, executable};
189+
}
190+
191+
createDbconnectDebugConfig(
192+
targetResource: Uri,
193+
executable: string
194+
): DatabricksPythonDebugConfiguration {
195+
return {
143196
program: targetResource.fsPath,
144197
type: "debugpy",
145198
name: "Databricks Connect",
146199
request: "launch",
147200
databricks: true,
148201
console: "integratedTerminal",
149202
env: {...process.env},
203+
// Pin debugpy to the interpreter we have verified. Without this
204+
// debugpy falls back to the Python extension's selected
205+
// interpreter for the workspace folder, which can differ from the
206+
// environment used by "run" and by the verification checks.
207+
python: executable,
150208
};
209+
}
151210

152-
debug.startDebugging(
211+
async debugFileUsingDbconnect(resource?: Uri) {
212+
const launch = await this.resolveDbconnectLaunch(resource);
213+
if (!launch) {
214+
return;
215+
}
216+
const config = this.createDbconnectDebugConfig(
217+
launch.targetResource,
218+
launch.executable
219+
);
220+
221+
await debug.startDebugging(
153222
this.workspaceFolderManager.activeWorkspaceFolder,
154223
config
155224
);
@@ -161,21 +230,11 @@ export class RunCommands {
161230
}
162231

163232
async runFileUsingDbconnect(resource?: Uri) {
164-
if (!(await this.checkDbconnectEnabled())) {
165-
return;
166-
}
167-
168-
const targetResource = this.getTargetResource(resource);
169-
if (!targetResource) {
170-
window.showErrorMessage("Open a file to run");
171-
return;
172-
}
173-
174-
const executable = await this.pythonExtension.getPythonExecutable();
175-
if (!executable) {
176-
window.showErrorMessage("No python executable found");
233+
const launch = await this.resolveDbconnectLaunch(resource);
234+
if (!launch) {
177235
return;
178236
}
237+
const {targetResource, executable} = launch;
179238

180239
const terminal = window.activeTerminal ?? window.createTerminal();
181240
const bootstrapPath = this.context.asAbsolutePath(

0 commit comments

Comments
 (0)