Skip to content

Commit 36931ab

Browse files
Use disk-backed isolated hosts for packaged upgrade checks
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 12067f5 commit 36931ab

5 files changed

Lines changed: 167 additions & 29 deletions

File tree

‎build/releaseCompatibility/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ For the DevDiv cutover, supply `upgradeFromEnvironments` with the same artifact
4242

4343
Each boolean mode gets its own workspace, user-data directory, home, extension directory, and VS Code process. Settings are written **before host launch**. Two real `venv --without-pip` fixtures are created as direct workspace children, within the legacy locator's two-directory search depth, and their actual executable/prefix verified. The controller requires discovery of both fixtures before selecting each and invoking normal `python.execInTerminal`; observed candidates are retained even on failure. The script atomically writes a nonce, `sys.executable`, and `sys.prefix`; a terminal appearing or a command resolving is not a pass.
4444

45-
Only the independent test controller is an `extensionDevelopmentPath`. Its own public `ExtensionContext` must report test mode. Products must load from the exact installed VSIX directories, outside that sole development root, with the expected identities/versions. The public `Extension` object does not expose another extension's context mode; it remains unknown rather than fabricating a `Production` value. Installed-path and controller-only launch evidence prove these are not source/development-extension runs. A transparent, restored observer of the public Environments `runInTerminal` method proves core's on-mode delegation without replacing execution. Prototype methods are shadowed only on the instance and restored by removing the shadow; shared prototypes are never modified. An immutable API that cannot be observed is an explicit infrastructure error, not a pass.
45+
Only the independent test controller is an `extensionDevelopmentPath`. Its own public `ExtensionContext` must report test mode for single-phase checks, or development mode for real-storage upgrade hosts. Products must load from the exact installed VSIX directories, outside that sole development root, with the expected identities/versions. The public `Extension` object does not expose another extension's context mode; it remains unknown rather than fabricating a `Production` value. Installed-path and controller-only launch evidence prove these are not source/development-extension runs. A transparent, restored observer of the public Environments `runInTerminal` method proves core's on-mode delegation without replacing execution. Prototype methods are shadowed only on the instance and restored by removing the shadow; shared prototypes are never modified. An immutable API that cannot be observed is an explicit infrastructure error, not a pass.
4646

47-
For an upgrade, the baseline host fully exits before replacing only the installed Environments directory. Workspace settings, user profile, and selection storage are retained. The upgraded host must observe the last selected fixture **before any selection write**, and explicit true/false must survive. An unsuccessful baseline cannot certify an upgrade.
47+
For an upgrade, both phases use normal isolated VS Code hosts **without `--extensionTestsPath`**: [VS Code deliberately uses in-memory storage during extension tests](https://github.com/microsoft/vscode/blob/0870c2a0c7c0564e7631bfed2675573a94ba4455/src/vs/platform/storage/electron-main/storageMainService.ts#L99-L102). The independent controller runs on normal development-extension activation and requests a graceful quit after writing its report. The baseline fully exits before replacing only the installed Environments directory. Workspace settings, user profile, and selection storage are retained. An independent controller-owned global/workspace Memento token must survive the restart; failure is an infrastructure/storage error, not a product association verdict. The upgraded host must observe the last selected fixture **before any product selection write**, and explicit true/false must survive. Neither the probe nor the runner reseeds product selection. An unsuccessful baseline cannot certify an upgrade.
4848

4949
## Evidence and isolation
5050

‎build/releaseCompatibility/checks.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,36 @@ function profileSettings(mode, layout) {
387387
};
388388
}
389389

390+
async function storageContinuity(context, token, verifyExisting) {
391+
if (typeof token !== 'string' || token.length === 0) {
392+
throw new InfrastructureError('Persistent-host storage probe token is missing');
393+
}
394+
const key = 'pythonReleaseCompatibility.storageContinuity';
395+
let global;
396+
let workspace;
397+
try {
398+
if (!verifyExisting) {
399+
await context.globalState.update(key, token);
400+
await context.workspaceState.update(key, token);
401+
}
402+
global = context.globalState.get(key);
403+
workspace = context.workspaceState.get(key);
404+
} catch (error) {
405+
throw new InfrastructureError(`Controller storage probe failed: ${error.message}`);
406+
}
407+
if (global !== token || workspace !== token) {
408+
throw new InfrastructureError(
409+
'Controller storage did not persist across hosts; product selection is not certified',
410+
);
411+
}
412+
return {
413+
kind: 'disk-backed-normal-host',
414+
phase: verifyExisting ? 'verified-existing' : 'initialized',
415+
globalToken: global,
416+
workspaceToken: workspace,
417+
};
418+
}
419+
390420
function verifyPetIdentity(expected, actual, platform = process.platform) {
391421
check(samePath(expected.path, actual.path, platform), 'Runtime PET came from an unexpected supplier or location');
392422
check(expected.sha256 === actual.sha256, 'Runtime PET differs from the final VSIX binary');
@@ -496,6 +526,7 @@ module.exports = {
496526
locatorBackend,
497527
fixtureLayout,
498528
profileSettings,
529+
storageContinuity,
499530
verifyPetIdentity,
500531
observeMethod,
501532
childEnvironment,

‎build/releaseCompatibility/checks.test.js‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ const {
2626
locatorBackend,
2727
fixtureLayout,
2828
profileSettings,
29+
storageContinuity,
2930
verifyPetIdentity,
3031
observeMethod,
3132
childEnvironment,
3233
} = require('./checks');
33-
const { run, runProcess } = require('./run');
34+
const { run, runProcess, hostLaunchArgs } = require('./run');
3435
const { prepare } = require('./prepare');
3536
const { pack } = require('./pack');
3637
const { candidateCore, selfTest } = require('./selfTest');
@@ -314,6 +315,57 @@ describe('Packaged release compatibility guards', () => {
314315
assert.strictEqual(settings.workspace['extensions.autoUpdate'], undefined);
315316
});
316317

318+
it('launches upgrade hosts without extensionTestsPath and preserves the same explicit storage directories', () => {
319+
const testCase = {
320+
workspace: path.join(directory, 'workspace'),
321+
userData: path.join(directory, 'user-data'),
322+
extensions: path.join(directory, 'extensions'),
323+
persistentStorage: true,
324+
};
325+
const args = hostLaunchArgs(testCase);
326+
assert(!args.some((arg) => arg.startsWith('--extensionTestsPath')));
327+
assert(args.some((arg) => arg.startsWith('--extensionDevelopmentPath=')));
328+
assert.strictEqual(args[args.indexOf('--user-data-dir') + 1], testCase.userData);
329+
assert.strictEqual(args[args.indexOf('--extensions-dir') + 1], testCase.extensions);
330+
assert.strictEqual(args[0], testCase.workspace);
331+
});
332+
333+
it('proves controller Mementos persist before attributing missing product selection to an upgrade', async () => {
334+
const global = new Map();
335+
const workspace = new Map();
336+
const memento = (values) => ({
337+
get: (key) => values.get(key),
338+
update: async (key, value) => values.set(key, value),
339+
});
340+
const baseline = { globalState: memento(global), workspaceState: memento(workspace) };
341+
await storageContinuity(baseline, 'same-profile-token', false);
342+
const upgraded = { globalState: memento(global), workspaceState: memento(workspace) };
343+
assert.strictEqual((await storageContinuity(upgraded, 'same-profile-token', true)).phase, 'verified-existing');
344+
const resetHost = { globalState: memento(new Map()), workspaceState: memento(new Map()) };
345+
await assert.rejects(storageContinuity(resetHost, 'same-profile-token', true), InfrastructureError);
346+
assert.strictEqual(resetHost.globalState.get('pythonReleaseCompatibility.storageContinuity'), undefined);
347+
await assert.rejects(storageContinuity(upgraded, 'wrong-profile-token', true), InfrastructureError);
348+
const readOnlyAfter = {
349+
globalState: {
350+
get: () => 'same-profile-token',
351+
update: () => assert.fail('Must not reseed after restart'),
352+
},
353+
workspaceState: {
354+
get: () => 'same-profile-token',
355+
update: () => assert.fail('Must not reseed after restart'),
356+
},
357+
};
358+
await storageContinuity(readOnlyAfter, 'same-profile-token', true);
359+
const failedStore = {
360+
globalState: {
361+
update: async () => {
362+
throw new Error('disk unavailable');
363+
},
364+
},
365+
};
366+
await assert.rejects(storageContinuity(failedStore, 'same-profile-token', false), InfrastructureError);
367+
});
368+
317369
it('accepts positively observed legacy JS discovery only for off mode, without fabricating PET identity', () => {
318370
const legacy = legacyDiscoveryLines(
319371
'[info] Searching for custom virtual environments',

‎build/releaseCompatibility/controller/index.js‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,27 @@ const {
1313
verifyInstalledExtension,
1414
verifyPythonResult,
1515
observeMethod,
16+
storageContinuity,
1617
} = require('../checks');
1718

1819
const TIMEOUT = 60000;
1920
let controllerContext;
2021

2122
function activate(context) {
2223
controllerContext = context;
24+
if (process.env.PYTHON_COMPATIBILITY_PERSISTENT_HOST === '1') {
25+
setTimeout(() => {
26+
run()
27+
.then(
28+
() => vscode.commands.executeCommand('workbench.action.quit'),
29+
(error) => {
30+
console.error('Persistent compatibility case failed', error);
31+
return vscode.commands.executeCommand('workbench.action.quit');
32+
},
33+
)
34+
.catch((error) => console.error('Persistent compatibility host could not quit gracefully', error));
35+
}, 0);
36+
}
2337
}
2438

2539
async function bounded(label, operation) {
@@ -157,8 +171,9 @@ async function run() {
157171
check(controller, 'Independent test controller is missing');
158172
await bounded('controller activation', controller.activate());
159173
check(
160-
controllerContext?.extensionMode === vscode.ExtensionMode.Test,
161-
'Controller is not in the isolated test host',
174+
controllerContext?.extensionMode ===
175+
(testCase.persistentStorage ? vscode.ExtensionMode.Development : vscode.ExtensionMode.Test),
176+
'Controller is not in the expected isolated host mode',
162177
);
163178
check(
164179
samePath(controllerContext.extensionPath, __dirname),
@@ -167,7 +182,15 @@ async function run() {
167182
report.controller = {
168183
location: controllerContext.extensionPath,
169184
extensionMode: controllerContext.extensionMode,
185+
persistentStorage: Boolean(testCase.persistentStorage),
170186
};
187+
if (testCase.persistentStorage) {
188+
report.storage = await storageContinuity(
189+
controllerContext,
190+
testCase.storageProbeToken,
191+
Boolean(testCase.persisted),
192+
);
193+
}
171194
report.vscode = { version: vscode.version, platform: process.platform, arch: process.arch };
172195
report.configurationBefore = configuredMode(testCase);
173196
const core = extension(testCase.core);

‎build/releaseCompatibility/run.js‎

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,16 @@ async function makeProfile(directory, mode, pythonExecutable) {
307307
check(samePath(fixture.prefix, prefix), 'Fixture bootstrap did not create an isolated venv');
308308
fixtures.push(fixture);
309309
}
310-
return { directory, workspace, userData, home, extensions, env, fixtures };
310+
return {
311+
directory,
312+
workspace,
313+
userData,
314+
home,
315+
extensions,
316+
env,
317+
fixtures,
318+
storageProbeToken: crypto.randomBytes(16).toString('hex'),
319+
};
311320
}
312321

313322
async function runPhase(plan, profile, core, environments, phase, vscodeExecutablePath, persisted) {
@@ -320,7 +329,7 @@ async function runPhase(plan, profile, core, environments, phase, vscodeExecutab
320329
const caseFile = path.join(phaseDirectory, 'case.json');
321330
const logDirectory = path.join(profile.userData, 'logs');
322331
const offsets = logOffsets(logDirectory);
323-
writeJson(caseFile, {
332+
const testCase = {
324333
mode: profile.mode,
325334
phase,
326335
workspace: profile.workspace,
@@ -329,19 +338,29 @@ async function runPhase(plan, profile, core, environments, phase, vscodeExecutab
329338
environments: { id: environments.id, version: environments.version, location: environmentLocation },
330339
fixtures: profile.fixtures,
331340
persisted,
341+
persistentStorage: Boolean(plan.upgradeFromEnvironments),
342+
storageProbeToken: profile.storageProbeToken,
332343
vscodeVersion: plan.vscodeVersion,
333344
nonce: crypto.randomBytes(16).toString('hex'),
334345
vscodeExecutablePath,
335346
userData: profile.userData,
336347
extensions: profile.extensions,
337-
});
348+
};
349+
writeJson(caseFile, testCase);
338350
try {
339-
const result = await runProcess(process.execPath, [__filename, '--worker', caseFile], {
340-
cwd: phaseDirectory,
341-
env: profile.env,
342-
timeout: HOST_TIMEOUT,
343-
log: path.join(phaseDirectory, 'process.log'),
344-
});
351+
const persistent = testCase.persistentStorage;
352+
const result = await runProcess(
353+
persistent ? vscodeExecutablePath : process.execPath,
354+
persistent ? hostLaunchArgs(testCase) : [__filename, '--worker', caseFile],
355+
{
356+
cwd: phaseDirectory,
357+
env: persistent
358+
? { ...profile.env, PYTHON_COMPATIBILITY_CASE: caseFile, PYTHON_COMPATIBILITY_PERSISTENT_HOST: '1' }
359+
: profile.env,
360+
timeout: HOST_TIMEOUT,
361+
log: path.join(phaseDirectory, 'process.log'),
362+
},
363+
);
345364
record.process = { pid: result.pid, exitCode: result.code, signal: result.signal, cleanup: result.cleanup };
346365
if (fs.existsSync(stateFile)) record.host = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
347366
if (record.host?.status === 'failed') throw new CheckFailure(record.host.error.message);
@@ -412,28 +431,41 @@ async function runPhase(plan, profile, core, environments, phase, vscodeExecutab
412431
return record;
413432
}
414433

434+
function hostLaunchArgs(testCase) {
435+
return [
436+
testCase.workspace,
437+
'--extensions-dir',
438+
testCase.extensions,
439+
'--user-data-dir',
440+
testCase.userData,
441+
'--disable-workspace-trust',
442+
'--skip-welcome',
443+
'--skip-release-notes',
444+
'--disable-updates',
445+
'--enable-proposed-api=ms-python.python',
446+
'--enable-proposed-api=ms-python.vscode-python-envs',
447+
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
448+
...(testCase.persistentStorage
449+
? ['--new-window', `--extensionDevelopmentPath=${path.join(__dirname, 'controller')}`]
450+
: []),
451+
];
452+
}
453+
415454
async function worker(caseFile) {
416455
const testCase = JSON.parse(fs.readFileSync(caseFile, 'utf8'));
456+
if (testCase.persistentStorage) {
457+
throw new InfrastructureError(
458+
'Persistent cases must use the directly owned normal host, not extensionTestsPath',
459+
);
460+
}
461+
const launchArgs = hostLaunchArgs(testCase);
417462
const { runTests } = require('@vscode/test-electron');
418463
await runTests({
419464
vscodeExecutablePath: testCase.vscodeExecutablePath,
420465
extensionDevelopmentPath: path.join(__dirname, 'controller'),
421466
extensionTestsPath: path.join(__dirname, 'controller', 'index.js'),
422467
extensionTestsEnv: { PYTHON_COMPATIBILITY_CASE: caseFile },
423-
launchArgs: [
424-
testCase.workspace,
425-
'--extensions-dir',
426-
testCase.extensions,
427-
'--user-data-dir',
428-
testCase.userData,
429-
'--disable-workspace-trust',
430-
'--skip-welcome',
431-
'--skip-release-notes',
432-
'--disable-updates',
433-
'--enable-proposed-api=ms-python.python',
434-
'--enable-proposed-api=ms-python.vscode-python-envs',
435-
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
436-
],
468+
launchArgs,
437469
});
438470
}
439471

@@ -559,4 +591,4 @@ if (require.main === module) {
559591
});
560592
}
561593

562-
module.exports = { run, runProcess, observeLogs, errorRecord };
594+
module.exports = { run, runProcess, observeLogs, errorRecord, hostLaunchArgs };

0 commit comments

Comments
 (0)