Skip to content

Commit 089aeba

Browse files
Scope inline-script routing hardening to a PEP 723 per-script guard
Replace the generic cross-manager selection-finalization machinery with a narrow PEP 723-only routing-mutation guard. Only a current (non-superseded) inline-routing operation may mutate a script's per-script routing override; the ordinary project/global active-selection and settings lanes are committed independently and are no longer suppressed by a stale inline operation. - Restore the origin/main batch settings construction/write order (no committedSelections, no pre-settings revision filter, no post-settings recheck) and the one-key commitSelectionOperation. - Add commitInlineRoutingOperation, gated to inlineScriptRouting + file .py scopes via getInlineScriptRoutingKey; feature-off and non-script scopes return true immediately and preserve the old flow. - Guard only the single-URI and batch inline routing blocks. Each script commits on its own per-file inline key, so two scripts under the same containing project no longer contend on a shared project revision (the first script now installs its override instead of skipping it). - The generic cross-manager settings write race is explicitly deferred. Tests: adapt the stale-non-inline-vs-newer-inline regression to assert the script still routes to inline without asserting containing-project suppression; add a same-project batch non-inline regression and a feature-off distinct-projects batch regression; remove the batch settings-ordering test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f881304e-3c5f-4144-9f0d-d80c68768d18
1 parent f20011f commit 089aeba

2 files changed

Lines changed: 110 additions & 109 deletions

File tree

src/features/envManagers.ts

Lines changed: 57 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import {
1919
InlineScriptRouteabilityChangeEvent,
2020
InlineScriptRoutingRegistry,
21+
getInlineScriptRoutingKey,
2122
} from '../common/inlineScript/routingRegistry';
2223
import { traceError, traceVerbose } from '../common/logging';
2324
import { StopWatch } from '../common/stopWatch';
@@ -420,33 +421,29 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
420421
}
421422

422423
if (scope instanceof Uri) {
423-
const inlineOperation =
424-
manager.id === INLINE_SCRIPT_MANAGER_ID
425-
? operation
426-
: (inlineOverrideHandoffOperation ?? inlineClearOperation);
427424
if (
428-
!this.commitSelectionOperations([
429-
{ key, operation },
430-
...(inlineOperation === undefined
431-
? []
432-
: [{ key: this.getInlineScriptSelectionKey(scope), operation: inlineOperation }]),
433-
])
434-
) {
435-
return;
436-
}
437-
this.updateInlineRoutingOverride(scope, manager, environment);
438-
this.clearInlineActiveSelection(scope, manager, inlineOperation);
439-
if (
440-
clearingInlineRoutingOverride &&
441-
(await this.publishEffectiveEnvironmentAfterOverrideClear(
425+
this.commitInlineRoutingOperation(
442426
scope,
443427
manager,
444-
key,
445428
operation,
429+
inlineClearOperation,
446430
inlineOverrideHandoffOperation,
447-
))
431+
)
448432
) {
449-
return;
433+
this.updateInlineRoutingOverride(scope, manager, environment);
434+
this.clearInlineActiveSelection(scope, manager, inlineOverrideHandoffOperation ?? inlineClearOperation);
435+
if (
436+
clearingInlineRoutingOverride &&
437+
(await this.publishEffectiveEnvironmentAfterOverrideClear(
438+
scope,
439+
manager,
440+
key,
441+
operation,
442+
inlineOverrideHandoffOperation,
443+
))
444+
) {
445+
return;
446+
}
450447
}
451448
}
452449
if (!publishInlineSelection) {
@@ -507,13 +504,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
507504
if (Array.isArray(scope) && scope.every((s) => s instanceof Uri)) {
508505
const selections = scope.map((uri) => this.beginPendingSelection(uri, manager));
509506
await manager.set(scope, environment);
510-
// Commit the winning (non-superseded) selections BEFORE persisting settings so an
511-
// out-of-order older batch cannot write a manager setting to settings.json while its
512-
// matching routing/selection update is rejected (settings/routing divergence).
513-
const committedSelections = selections.filter((selection) =>
514-
this.commitPendingSelection(selection, manager),
515-
);
516-
committedSelections.forEach((selection) => {
507+
selections.forEach((selection) => {
517508
const m = this.getEnvironmentManager(selection.scope);
518509
// Always add settings when persisting, OR when manager differs
519510
if (
@@ -530,18 +521,27 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
530521
if (shouldPersistSettings) {
531522
await setAllManagerSettings(settings);
532523
}
533-
committedSelections.forEach((selection) => {
534-
// Re-validate across the (awaited) settings write: a newer selection may have
535-
// superseded this one while settings were being persisted, in which case its
536-
// routing override and active-selection publish must be skipped.
537-
if (!this.commitPendingSelection(selection, manager)) {
538-
return;
524+
selections.forEach((selection) => {
525+
// Only a current (non-superseded) PEP 723 routing operation may mutate the
526+
// per-script override; the ordinary project/global selection lane below is
527+
// committed independently so a stale inline op cannot suppress it.
528+
if (
529+
this.commitInlineRoutingOperation(
530+
selection.scope,
531+
manager,
532+
selection.operation,
533+
selection.inlineClearOperation,
534+
)
535+
) {
536+
this.updateInlineRoutingOverride(selection.scope, manager, environment);
537+
this.clearInlineActiveSelection(selection.scope, manager, selection.inlineClearOperation);
539538
}
540-
this.updateInlineRoutingOverride(selection.scope, manager, environment);
541-
this.clearInlineActiveSelection(selection.scope, manager, selection.inlineClearOperation);
542539
if (!selection.publishInlineSelection) {
543540
return;
544541
}
542+
if (!this.commitSelectionOperation(selection.key, selection.operation)) {
543+
return;
544+
}
545545
const oldEnv = this._activeSelection.get(selection.key);
546546
if (!this.isSameEnvironment(oldEnv, environment)) {
547547
this._activeSelection.set(selection.key, environment);
@@ -955,18 +955,26 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
955955
}
956956
}
957957

958-
private commitPendingSelection(
959-
selection: PendingEnvironmentSelection,
958+
private commitInlineRoutingOperation(
959+
scope: Uri,
960960
manager: InternalEnvironmentManager,
961+
selectionOperation: number,
962+
inlineClearOperation?: number,
963+
inlineOverrideHandoffOperation?: number,
961964
): boolean {
962-
const inlineOperation =
963-
manager.id === INLINE_SCRIPT_MANAGER_ID ? selection.operation : selection.inlineClearOperation;
964-
return this.commitSelectionOperations([
965-
{ key: selection.key, operation: selection.operation },
966-
...(inlineOperation === undefined
967-
? []
968-
: [{ key: this.getInlineScriptSelectionKey(selection.scope), operation: inlineOperation }]),
969-
]);
965+
// Gate strictly to the manually enabled PEP 723 routing feature and to file .py scopes.
966+
// For the feature-off or non-script case, proceed exactly as before.
967+
if (!this.inlineScriptRouting || getInlineScriptRoutingKey(scope) === undefined) {
968+
return true;
969+
}
970+
const operation =
971+
manager.id === INLINE_SCRIPT_MANAGER_ID
972+
? selectionOperation
973+
: (inlineOverrideHandoffOperation ?? inlineClearOperation);
974+
return (
975+
operation === undefined ||
976+
this.commitSelectionOperation(this.getInlineScriptSelectionKey(scope), operation)
977+
);
970978
}
971979

972980
private canPersistManagerSettingForScope(
@@ -988,24 +996,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
988996
}
989997

990998
private commitSelectionOperation(key: string, operation: number): boolean {
991-
return this.commitSelectionOperations([{ key, operation }]);
992-
}
993-
994-
private commitSelectionOperations(
995-
operations: readonly { readonly key: string; readonly operation: number }[],
996-
): boolean {
997-
const latestByKey = new Map<string, number>();
998-
for (const { key, operation } of operations) {
999-
latestByKey.set(key, Math.max(latestByKey.get(key) ?? operation, operation));
1000-
}
1001-
for (const [key, operation] of latestByKey) {
1002-
if ((this._selectionRevisions.get(key) ?? 0) > operation) {
1003-
return false;
1004-
}
1005-
}
1006-
for (const [key, operation] of latestByKey) {
1007-
this._selectionRevisions.set(key, operation);
999+
if ((this._selectionRevisions.get(key) ?? 0) > operation) {
1000+
return false;
10081001
}
1002+
this._selectionRevisions.set(key, operation);
10091003
return true;
10101004
}
10111005

src/test/features/envManagers.lastKnown.unit.test.ts

Lines changed: 53 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,15 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
334334
const olderSelection = envManagers.setEnvironment(script, selectedEnvironment, false);
335335
await olderSelectionStarted;
336336
await envManagers.setEnvironment(script, inlineEnvironment, false);
337-
const eventsAfterNewerSelection = [...events];
338337
releaseOlderSelection!();
339338
await olderSelection;
340339

340+
// The stale non-inline selection must not hijack the script's PEP 723 routing…
341341
assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, inlineId);
342342
assert.strictEqual(envManagers.getLastKnownEnvironment(script), inlineEnvironment);
343-
assert.deepStrictEqual(events, eventsAfterNewerSelection);
344-
assert.ok(events.every((event) => event.new !== selectedEnvironment));
343+
assert.ok(events.some((event) => event.new === inlineEnvironment));
344+
// …but its ordinary containing-project selection lane is independent and is not suppressed.
345+
assert.ok(events.some((event) => event.new === selectedEnvironment));
345346
});
346347

347348
test('does not let an older batch inline selection clear a newer non-inline override', async () => {
@@ -388,6 +389,55 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
388389
assert.deepStrictEqual(events, eventsAfterNewerSelection);
389390
});
390391

392+
test('routes each same-project script to its own override in a batch non-inline selection', async () => {
393+
const project = { name: 'project', uri: Uri.file('/workspace/project') };
394+
const firstScript = Uri.file('/workspace/project/first.py');
395+
const secondScript = Uri.file('/workspace/project/second.py');
396+
projectsByUri.set(firstScript.toString(), project);
397+
projectsByUri.set(secondScript.toString(), project);
398+
let selectedEnvironment: PythonEnvironment;
399+
const selectedId = registerManager(async () => selectedEnvironment, async () => undefined, 'venv');
400+
registerManager(async () => undefined, async () => undefined, 'inline-script');
401+
selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } };
402+
markInlineScript(firstScript);
403+
markInlineScript(secondScript);
404+
405+
await envManagers.setEnvironments([firstScript, secondScript], selectedEnvironment, false);
406+
407+
// Each script commits on its own per-file inline key, so the shared containing-project
408+
// revision cannot make the first script skip installing its routing override.
409+
assert.strictEqual(envManagers.getEnvironmentManager(firstScript)?.id, selectedId);
410+
assert.strictEqual(envManagers.getEnvironmentManager(secondScript)?.id, selectedId);
411+
});
412+
413+
test('applies a normal non-inline batch across distinct projects without a routing registry', async () => {
414+
recreateEnvManagersWithoutRouting();
415+
const projectOne = { name: 'one', uri: Uri.file('/workspace/one') };
416+
const projectTwo = { name: 'two', uri: Uri.file('/workspace/two') };
417+
projectsByUri.set(projectOne.uri.toString(), projectOne);
418+
projectsByUri.set(projectTwo.uri.toString(), projectTwo);
419+
const managerSet = sinon.stub().resolves();
420+
let selectedEnvironment: PythonEnvironment;
421+
const managerId = registerManager(async () => selectedEnvironment, managerSet, 'venv');
422+
selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId } };
423+
const settings = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves();
424+
const events: DidChangeEnvironmentEventArgs[] = [];
425+
envManagers.onDidChangeActiveEnvironment((event) => events.push(event));
426+
427+
await envManagers.setEnvironments([projectOne.uri, projectTwo.uri], selectedEnvironment);
428+
429+
assert.strictEqual(managerSet.callCount, 1);
430+
assert.deepStrictEqual(managerSet.firstCall.args[0], [projectOne.uri, projectTwo.uri]);
431+
assert.strictEqual(envManagers.getLastKnownEnvironment(projectOne.uri), selectedEnvironment);
432+
assert.strictEqual(envManagers.getLastKnownEnvironment(projectTwo.uri), selectedEnvironment);
433+
assert.deepStrictEqual(
434+
events.map((event) => event.new),
435+
[selectedEnvironment, selectedEnvironment],
436+
);
437+
assert.strictEqual(settings.callCount, 1);
438+
assert.strictEqual(settings.firstCall.args[0].length, 2);
439+
});
440+
391441
test('publishes inline environments with the same ID at different paths', async () => {
392442
const scope = Uri.file('/workspace/script.py');
393443
const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script');
@@ -967,49 +1017,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
9671017
]);
9681018
});
9691019

970-
test('does not persist manager settings for a batch superseded before its settings write', async () => {
971-
const script = Uri.file('/workspace/script.py');
972-
const scriptProject = { name: 'script.py', uri: script };
973-
projectsByUri.set(script.toString(), scriptProject);
974-
let releaseStaleBatch: (() => void) | undefined;
975-
let signalStaleBatch: (() => void) | undefined;
976-
const staleBatchStarted = new Promise<void>((resolve) => {
977-
signalStaleBatch = resolve;
978-
});
979-
const staleBatchGate = new Promise<void>((resolve) => {
980-
releaseStaleBatch = resolve;
981-
});
982-
const managerSet = sinon.stub();
983-
managerSet.onFirstCall().callsFake(async () => {
984-
signalStaleBatch!();
985-
await staleBatchGate;
986-
});
987-
managerSet.onSecondCall().resolves();
988-
const managerId = registerManager(async () => undefined, managerSet, 'inline-script');
989-
const staleEnv = { ...makeEnv('stale'), envId: { id: 'stale', managerId } };
990-
const newerEnv = { ...makeEnv('newer'), envId: { id: 'newer', managerId } };
991-
stubPackageManager();
992-
const settings = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves();
993-
994-
const staleBatch = envManagers.setEnvironments([script], staleEnv);
995-
await staleBatchStarted;
996-
await envManagers.setEnvironment(script, newerEnv);
997-
releaseStaleBatch!();
998-
await staleBatch;
999-
1000-
// The newer selection persists the manager setting exactly once.
1001-
assert.deepStrictEqual(settings.firstCall.args[0], [
1002-
{
1003-
project: scriptProject,
1004-
envManager: managerId,
1005-
packageManager: 'ms-python.python:pip',
1006-
},
1007-
]);
1008-
// The superseded batch must not persist a stale selection to settings.json; it writes nothing.
1009-
assert.strictEqual(settings.callCount, 2);
1010-
assert.deepStrictEqual(settings.secondCall.args[0], []);
1011-
});
1012-
10131020
test('retains an earlier successful refresh when a later refresh fails', async () => {
10141021
const refreshed = makeEnv('refreshed');
10151022
let resolveFirst: ((environment: PythonEnvironment) => void) | undefined;

0 commit comments

Comments
 (0)