Skip to content
Open
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
18 changes: 18 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,12 @@
"default": true,
"description": "%configuration.aspire.enableGutterDecorations%",
"scope": "window"
},
"aspire.enableAutoRestore": {
"type": "boolean",
"default": true,
"description": "%configuration.aspire.enableAutoRestore%",
"scope": "window"
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running apphosts (used in global view). Minimum: 1000.",
"configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in apphost files.",
"configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in apphost files.",
"configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.",
"command.runAppHost": "Run Aspire apphost",
"command.debugAppHost": "Debug Aspire apphost",
"aspire-vscode.strings.noCsprojFound": "No apphost found in the current workspace.",
Expand Down Expand Up @@ -117,6 +118,11 @@
"aspire-vscode.strings.selectDirectoryTitle": "Select directory",
"aspire-vscode.strings.selectFileTitle": "Select file",
"aspire-vscode.strings.enterPipelineStep": "Enter the pipeline step to execute",
"aspire-vscode.strings.runningAspireRestore": "Running aspire restore on {0} ...",
"aspire-vscode.strings.runningAspireRestoreProgress": "Running aspire restore ({0}/{1} projects)...",
"aspire-vscode.strings.aspireRestoreCompleted": "Aspire restore completed for {0}.",
"aspire-vscode.strings.aspireRestoreAllCompleted": "Aspire restore completed",
"aspire-vscode.strings.aspireRestoreFailed": "aspire restore failed for {0}: {1}",
"viewsContainers.aspirePanel.title": "Aspire",
"views.runningAppHosts.name": "Running AppHosts",
"views.runningAppHosts.loading": "Searching for running apphosts...",
Expand Down
9 changes: 9 additions & 0 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { settingsCommand } from './commands/settings';
import { openLocalSettingsCommand, openGlobalSettingsCommand } from './commands/openSettings';
import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace } from './utils/workspace';
import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider';
import { AspirePackageRestoreProvider } from './utils/AspirePackageRestoreProvider';
import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider';
import { AppHostDataRepository } from './views/AppHostDataRepository';
import { installCliStableCommand, installCliDailyCommand, verifyCliInstalledCommand } from './commands/walkthroughCommands';
Expand Down Expand Up @@ -191,6 +192,14 @@ export async function activate(context: vscode.ExtensionContext) {
// any user-visible errors should be handled within checkForExistingAppHostPathInWorkspace.
});
}

// Auto-restore: run `aspire restore` on workspace open and when aspire.config.json changes
const packageRestoreProvider = new AspirePackageRestoreProvider(terminalProvider);
context.subscriptions.push(packageRestoreProvider);
void packageRestoreProvider.activate().catch(err => {
extensionLogOutputChannel.warn(`Auto-restore activation failed: ${String(err)}`);
});

// Return exported API for tests or other extensions
return {
rpcServerInfo: rpcServer.connectionInfo,
Expand Down
5 changes: 5 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export const openCliInstallInstructions = vscode.l10n.t('See CLI installation in
export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.');
export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path);
export const selectDirectoryTitle = vscode.l10n.t('Select directory');
export const runningAspireRestore = (configPath: string) => vscode.l10n.t('Running aspire restore on {0}...', configPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const runningAspireRestore = (configPath: string) => vscode.l10n.t('Running aspire restore on {0}...', configPath);
export const runningAspireRestore = (configPath: string) => vscode.l10n.t('Running aspire restore on {0} ...', configPath);

export const runningAspireRestoreProgress = (completed: number, total: number) => vscode.l10n.t('Running aspire restore ({0}/{1} projects)...', completed, total);
export const aspireRestoreCompleted = (configPath: string) => vscode.l10n.t('Aspire restore completed for {0}.', configPath);
export const aspireRestoreAllCompleted = vscode.l10n.t('Aspire restore completed');
export const aspireRestoreFailed = (configPath: string, error: string) => vscode.l10n.t('aspire restore failed for {0}: {1}', configPath, error);
export const selectFileTitle = vscode.l10n.t('Select file');
export const enterPipelineStep = vscode.l10n.t('Enter the pipeline step to execute');

Expand Down
204 changes: 204 additions & 0 deletions extension/src/utils/AspirePackageRestoreProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import * as vscode from 'vscode';
import path from 'path';
import { aspireConfigFileName } from './cliTypes';
import { findAspireSettingsFiles } from './workspace';
import { ChildProcessWithoutNullStreams } from 'child_process';
import { spawnCliProcess } from '../debugger/languages/cli';
import { AspireTerminalProvider } from './AspireTerminalProvider';
import { extensionLogOutputChannel } from './logging';
import { getEnableAutoRestore } from './settings';
import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed } from '../loc/strings';

/**
* Runs `aspire restore` on workspace open and whenever aspire.config.json content changes
* (e.g. after a git branch switch).
*/
export class AspirePackageRestoreProvider implements vscode.Disposable {
private static readonly _maxConcurrency = 4;

private readonly _disposables: vscode.Disposable[] = [];
private readonly _terminalProvider: AspireTerminalProvider;
private readonly _statusBarItem: vscode.StatusBarItem;
private readonly _lastContent = new Map<string, string>(); // fsPath → content
private readonly _active = new Map<string, string>(); // configDir → relativePath
private readonly _childProcesses = new Set<ChildProcessWithoutNullStreams>();
private readonly _timeouts = new Set<ReturnType<typeof setTimeout>>();
private readonly _pendingRestore = new Set<string>(); // configDirs needing re-restore
private _total = 0;
private _completed = 0;

constructor(terminalProvider: AspireTerminalProvider) {
this._terminalProvider = terminalProvider;
this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0);
this._disposables.push(this._statusBarItem);
}

async activate(): Promise<void> {
this._disposables.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('aspire.enableAutoRestore') && getEnableAutoRestore()) {
this._restoreAll();
}
})
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_watchConfigFiles() is only called once at the end of activate(). When the user toggles the setting on, this handler only calls _restoreAll() — watchers are never created. Changes to aspire.config.json after toggling the setting on won't be detected until the extension is reloaded.

This should also call _watchConfigFiles() (guarding against duplicate watchers), or the watchers should be set up unconditionally and just check the setting in _onChanged (which it already does).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this._restoreAll() returns a promise but is called inside a synchronous onDidChangeConfiguration callback without void or .catch(), producing an unhandled floating promise. If it rejects, the error is silently lost.

The call site in extension.ts correctly uses void ... .catch() — apply the same pattern here:

void this._restoreAll().catch(err => {
    extensionLogOutputChannel.warn(`Auto-restore failed: ${String(err)}`);
});


if (!getEnableAutoRestore()) {
extensionLogOutputChannel.info('Auto-restore is disabled');
return;
}

await this._restoreAll();
this._watchConfigFiles();
}

private async _restoreAll(): Promise<void> {
const allConfigs = await findAspireSettingsFiles();
const configs = allConfigs.filter(uri => uri.fsPath.endsWith(aspireConfigFileName));
if (configs.length === 0) {
return;
}

this._total = configs.length;
this._completed = 0;

const pending = new Set<Promise<void>>();
for (const uri of configs) {
const p = this._restoreIfChanged(uri, true).finally(() => pending.delete(p));
pending.add(p);
if (pending.size >= AspirePackageRestoreProvider._maxConcurrency) {
await Promise.race(pending);
}
}
await Promise.all(pending);
}

private _watchConfigFiles(): void {
for (const folder of vscode.workspace.workspaceFolders ?? []) {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(folder, `**/${aspireConfigFileName}`)
);
watcher.onDidChange(uri => this._onChanged(uri));
watcher.onDidCreate(uri => this._onChanged(uri));
this._disposables.push(watcher);
}
}

private async _onChanged(uri: vscode.Uri): Promise<void> {
if (!getEnableAutoRestore()) {
return;
}
if (this._active.size === 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to worry about _onChanged getting called multiple times in parallel?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be safer to cancel an existing restore task for a given uri, if it exists. I’ll make that change

this._total = 1;
this._completed = 0;
}
await this._restoreIfChanged(uri, false);
}

private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When _active.size === 0, the counters are reset to total=1, completed=0. But if the watcher fires for two different files in quick succession while no restore is active, the first call sets total=1, then the second call also resets total=1 before the first restore finishes. The progress display will show (0/1 projects) even though 2 restores are now pending.

Minor UX issue — consider incrementing _total instead of resetting, or tracking pending items more precisely.

let content: string;
try {
content = (await vscode.workspace.fs.readFile(uri)).toString();
} catch (error) {
extensionLogOutputChannel.warn(`Failed to read ${uri.fsPath}: ${error}`);
return;
}

const prev = this._lastContent.get(uri.fsPath);
if (!isInitial && prev === content) {
return;
}

const configDir = path.dirname(uri.fsPath);
const relativePath = vscode.workspace.asRelativePath(uri);
extensionLogOutputChannel.info(`${isInitial ? 'Initial' : 'Changed'} restore for ${relativePath}`);

// Don't update baseline until restore succeeds; queue re-restore if one is already active
if (this._active.has(configDir)) {
this._pendingRestore.add(configDir);
return;
}

this._lastContent.set(uri.fsPath, content);
try {
await this._runRestore(configDir, relativePath);
} catch (error) {
extensionLogOutputChannel.warn(`Restore failed for ${relativePath}: ${error}`);
}

// If a change arrived while we were restoring, re-read and restore again
if (this._pendingRestore.delete(configDir)) {
await this._restoreIfChanged(uri, false);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 124 says "Don't update baseline until restore succeeds" but this._lastContent.set(uri.fsPath, content) here runs unconditionally before _runRestore is awaited. If the restore fails, the baseline is already updated, so a subsequent file-watcher event with the same content will be skipped (the prev === content check on line 112 will match).

The content should only be saved in _lastContent after a successful restore, or reverted on failure.

}

private async _runRestore(configDir: string, relativePath: string): Promise<void> {
this._active.set(configDir, relativePath);
this._showProgress();

const cliPath = await this._terminalProvider.getAspireCliExecutablePath();
await new Promise<void>((resolve, reject) => {
const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], {
workingDirectory: configDir,
noExtensionVariables: true,
exitCallback: code => {
if (code === 0) {
extensionLogOutputChannel.info(aspireRestoreCompleted(relativePath));
resolve();
} else {
extensionLogOutputChannel.warn(aspireRestoreFailed(relativePath, `exit code ${code}`));
reject(new Error(`exit code ${code}`));
}
},
errorCallback: error => {
extensionLogOutputChannel.warn(aspireRestoreFailed(relativePath, error.message));
reject(error);
},
});
this._childProcesses.add(proc);
const timeout = setTimeout(() => { proc.kill(); reject(new Error('restore timed out')); }, 120_000);
this._timeouts.add(timeout);
proc.on('close', () => {
clearTimeout(timeout);
this._timeouts.delete(timeout);
this._childProcesses.delete(proc);
});
}).finally(() => {
this._active.delete(configDir);
this._completed++;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the timeout fires, it calls proc.kill() then reject(new Error('restore timed out')). Killing the process triggers the close event, which fires exitCallback with a non-zero/null code, calling reject() a second time. While a second reject on an already-settled promise is a no-op in JS, the exitCallback still logs a misleading "aspire restore failed … exit code null" warning after the real "timed out" error.

Consider adding a settled flag to skip exitCallback/errorCallback once the timeout has already rejected.

this._showProgress();
if (this._active.size === 0) {
const hideTimeout = setTimeout(() => {
this._timeouts.delete(hideTimeout);
if (this._active.size === 0) { this._statusBarItem.hide(); }
}, 5000);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5000 - should this be a const from somewhere?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it would be a good idea to-I’ll add

this._timeouts.add(hideTimeout);
}
});
}

private _showProgress(): void {
if (this._active.size === 0) {
this._statusBarItem.text = `$(check) ${aspireRestoreAllCompleted}`;
} else if (this._total <= 1) {
this._statusBarItem.text = `$(sync~spin) ${runningAspireRestore([...this._active.values()][0])}`;
} else {
this._statusBarItem.text = `$(sync~spin) ${runningAspireRestoreProgress(this._completed, this._total)}`;
}
this._statusBarItem.show();
}

dispose(): void {
for (const proc of this._childProcesses) {
proc.kill();
}
this._childProcesses.clear();
for (const timeout of this._timeouts) {
clearTimeout(timeout);
}
this._timeouts.clear();
for (const d of this._disposables) {
d.dispose();
}
this._disposables.length = 0;
Comment on lines +191 to +202
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a TS noob. Do we need to handle exceptions here like if killing a process fails?

}
}
4 changes: 4 additions & 0 deletions extension/src/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ function getAspireConfig(): vscode.WorkspaceConfiguration {
export function getRegisterMcpServerInWorkspace(): boolean {
return getAspireConfig().get<boolean>(registerMcpServerInWorkspaceSettingName, false);
}

export function getEnableAutoRestore(): boolean {
return getAspireConfig().get<boolean>('enableAutoRestore', true);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"sdk": {
"version": "13.3.0-preview.1.26163.4"
"appHost": {
"path": "AppHost/apphost.ts",
"language": "typescript/nodejs"
},
"channel": "daily",
"packages": {
"Aspire.Hosting.Azure.Storage": "13.3.0-preview.1.26167.8",
"Aspire.Hosting.JavaScript": "13.3.0-preview.1.26167.8",
"Aspire.Hosting.Azure": "13.3.0-preview.1.26167.8",
"Aspire.Hosting.Azure.EventHubs": "13.3.0-preview.1.26167.8"
"Aspire.Hosting.Azure.Storage": "13.2.0",
"Aspire.Hosting.JavaScript": "13.2.0",
"Aspire.Hosting.Azure": "13.2.0",
"Aspire.Hosting.Azure.EventHubs": "13.2.0"
}

}
10 changes: 3 additions & 7 deletions playground/TypeScriptApps/RpsArena/aspire.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@
"appHost": {
"path": "apphost.ts"
},
"sdk": {
"version": "13.3.0-preview.1.26163.4"
},
"channel": "daily",
"packages": {
"Aspire.Hosting.Python": "13.3.0-preview.1.26167.8",
"Aspire.Hosting.PostgreSQL": "13.3.0-preview.1.26167.8",
"Aspire.Hosting.JavaScript": "13.3.0-preview.1.26167.8"
"Aspire.Hosting.Python": "13.2.0",
"Aspire.Hosting.PostgreSQL": "13.2.0",
"Aspire.Hosting.JavaScript": "13.2.0"
Comment on lines -10 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be using the latest main builds? What effect would this change have?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Practically, no difference. What would be really nice is if there were a way to avoid putting a version altogether and just taking the latest (or just allowing a “latest” tag).

}
}
Loading