diff --git a/README.md b/README.md index 3c4bd66..3a40072 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # .NET Solution Launcher -Startup project and solution configuration in the status bar, with build and debug that -actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush), next to -the `.NET Hot Reload` extension in this repo. +Startup project and solution configuration in the status bar, with build, debug, run and +hot reload that actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush). ``` -$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) +$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) $(flame) Hot Reload ``` - The **project item** shows which `.csproj` is the startup project, and picks another one @@ -13,6 +12,7 @@ $(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) - The **configuration item** shows the *solution* configuration — `Debug | x64`, `Test | x64`, `Release | Any CPU` — exactly as `MyGame.sln` lists them. - The **debug button** builds with that configuration and launches the startup project. +- The **flame** starts it under `dotnet watch` with hot reload instead. ## Why, when DotRush already has a status bar item @@ -93,6 +93,54 @@ Builder and Editor want different arguments, so these are layered, most specific The same layering applies to F5 through the launch.json entry: its `args` stay empty in the file and are filled in at launch time. +## Hot reload + +The third way to start the startup project, next to Debug and Run, and a rewrite of the +earlier `dotnet-hot-reload` extension now that the selection exists to build on: + +``` +dotnet watch run --project MyGame.Editor.csproj -c Debug --property:Platform=x64 -- +``` + +Same project, configuration, platform, arguments, environment and working directory as +F5. It runs in an integrated terminal named after the project, and the status bar item +next to the debug button follows the watcher's output: *watching*, *applied*, *failed*, +*restart needed*. Save a file and the change is applied; the flame in the terminal's +title is the same session. + +| Action | How | +| --- | --- | +| Start | The `$(flame) Hot Reload` status bar item, *.NET Solution: Run Startup Project with Hot Reload*, or `Ctrl+Alt+F5` | +| Apply, restart, attach, stop | Click the status bar item while it runs — one menu for all of them | +| Restart the application | Also `Ctrl+R` inside the watch terminal | + +**The debugger is not attached by default.** That is the biggest change from the old +extension, and deliberate. `dotnet watch` replaces the application process on every +rude edit, restart or crash, and each of those ends an attached debug session; keeping a +debugger attached across that meant guessing whether an ended session was a stop or a +swap, racing the outgoing process, and giving up on crash loops. Now *Attach Debugger* +finds the application below the watcher (by assembly name, so MSBuild is never picked) +and attaches when you ask. If the process is later replaced while the watcher still +runs, one notification says so and offers to re-attach. While attached, the flame and +restart buttons also appear in the debug toolbar. + +**Rude edits** — changes hot reload cannot apply — follow `dotnetSolution.hotReload.rudeEdit`: + +| Value | Behaviour | +| --- | --- | +| `restart` (default) | dotnet watch restarts the application on its own | +| `ask` | A notification offers *Restart* / *Keep running*; the answer goes to dotnet watch's console question | +| `warn` | Keeps the old code running and shows a warning with a *Restart* button | + +**Optimised builds cannot hot reload.** SDK 10's `dotnet watch` refuses when `Optimize` +is true and restarts on every change instead. Test and Release set it here, so starting +hot reload under those asks whether to switch to Debug first. + +**Solution build first.** `dotnet watch` only builds the project it runs. With +`dotnetSolution.hotReload.buildSolutionFirst` (default on) the normal solution build runs +before the watcher starts, so the Editor's post-build step finds the Builder even on a +clean checkout; the watcher's own build is then incremental. + ## Usage | Action | How | @@ -102,6 +150,7 @@ the file and are filled in at launch time. | Build / Rebuild / Clean | *.NET Solution: Build* etc., or the `dotnet-solution` tasks. `Ctrl+Shift+B` until a launch.json entry exists, after that VS Code's default build task | | Publish / Test | *.NET Solution: Publish Startup Project*, *.NET Solution: Run Tests* | | Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 | +| Hot reload | The `$(flame)` item, or `Ctrl+Alt+F5`. See above | | Run without debugging | *.NET Solution: Run Startup Project (without debugging)*, or `Ctrl+F5` | | Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` | @@ -157,6 +206,9 @@ The same entry is offered dynamically in the *Run and Debug* dropdown even witho | `dotnetSolution.publish.runtime` / `.args` | `""` / `[]` | `-r` and extra arguments for publish | | `dotnetSolution.test.args` | `[]` | Extra arguments for test | | `dotnetSolution.showProblemsOnFailure` | `true` | Open Problems when a task fails | +| `dotnetSolution.hotReload.rudeEdit` | `restart` | `restart`, `ask` or `warn` | +| `dotnetSolution.hotReload.buildSolutionFirst` | `true` | Solution build before `dotnet watch` | +| `dotnetSolution.hotReload.watchArgs` | `[]` | Extra arguments for `dotnet watch` itself | | `dotnetSolution.debugType` | `coreclr` | | | `dotnetSolution.syncDotRush` | `true` | Push the startup project to DotRush and follow its changes | | `dotnetSolution.syncDotRushWorkspaceProperties` | `false` | Write `Configuration=…;Platform=…` into `dotrush.roslyn.workspaceProperties` so IntelliSense sees the same `DefineConstants`. Off because DotRush reloads its workspace on every change | diff --git a/package.json b/package.json index 7f36348..4520053 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dotnet-solution-launcher", "displayName": ".NET Solution Launcher", - "description": "Startup project and solution configuration (Debug|x64, Test|x64, ...) in the status bar, with build and debug that honour them. Made for DotRush, which shows the configuration but not the project and never passes the platform.", + "description": "Startup project and solution configuration (Debug|x64, Test|x64, ...) in the status bar, with build, debug, run and hot reload that honour them. Made for DotRush, which shows the configuration but not the project and never passes the platform.", "version": "0.1.0", "publisher": "local", "license": "MIT", @@ -18,7 +18,9 @@ "solution", "configuration", "dotrush", - "startup project" + "startup project", + "hot reload", + "dotnet watch" ], "activationEvents": [ "onLanguage:csharp", @@ -83,6 +85,50 @@ "category": ".NET Solution", "icon": "$(play)" }, + { + "command": "dotnetSolution.hotReload.start", + "title": "Run Startup Project with Hot Reload", + "category": ".NET Solution", + "icon": "$(flame)" + }, + { + "command": "dotnetSolution.hotReload.apply", + "title": "Hot Reload: Apply Changes", + "category": ".NET Solution", + "icon": "$(flame)" + }, + { + "command": "dotnetSolution.hotReload.restart", + "title": "Hot Reload: Restart Application", + "category": ".NET Solution", + "icon": "$(debug-restart)" + }, + { + "command": "dotnetSolution.hotReload.stop", + "title": "Hot Reload: Stop", + "category": ".NET Solution", + "icon": "$(debug-stop)" + }, + { + "command": "dotnetSolution.hotReload.attach", + "title": "Hot Reload: Attach Debugger", + "category": ".NET Solution" + }, + { + "command": "dotnetSolution.hotReload.detach", + "title": "Hot Reload: Detach Debugger", + "category": ".NET Solution" + }, + { + "command": "dotnetSolution.hotReload.showTerminal", + "title": "Hot Reload: Show Terminal", + "category": ".NET Solution" + }, + { + "command": "dotnetSolution.hotReload.menu", + "title": "Hot Reload: Actions", + "category": ".NET Solution" + }, { "command": "dotnetSolution.generateLaunchConfig", "title": "Create launch.json and tasks.json entries", @@ -116,6 +162,46 @@ { "command": "dotnetSolution.setStartupProject", "when": "false" + }, + { + "command": "dotnetSolution.hotReload.apply", + "when": "dotnetSolution.hotReload.running" + }, + { + "command": "dotnetSolution.hotReload.restart", + "when": "dotnetSolution.hotReload.running" + }, + { + "command": "dotnetSolution.hotReload.stop", + "when": "dotnetSolution.hotReload.running" + }, + { + "command": "dotnetSolution.hotReload.attach", + "when": "dotnetSolution.hotReload.running && !dotnetSolution.hotReload.attached" + }, + { + "command": "dotnetSolution.hotReload.detach", + "when": "dotnetSolution.hotReload.attached" + }, + { + "command": "dotnetSolution.hotReload.showTerminal", + "when": "dotnetSolution.hotReload.running" + }, + { + "command": "dotnetSolution.hotReload.menu", + "when": "false" + } + ], + "debug/toolBar": [ + { + "command": "dotnetSolution.hotReload.apply", + "when": "dotnetSolution.hotReload.attached", + "group": "navigation@10" + }, + { + "command": "dotnetSolution.hotReload.restart", + "when": "dotnetSolution.hotReload.attached", + "group": "navigation@11" } ] }, @@ -301,6 +387,34 @@ "type": "boolean", "default": true, "description": "Open the Problems panel when a build, publish or test run fails." + }, + "dotnetSolution.hotReload.rudeEdit": { + "type": "string", + "enum": [ + "restart", + "ask", + "warn" + ], + "default": "restart", + "enumDescriptions": [ + "Restart the application automatically (DOTNET_WATCH_RESTART_ON_RUDE_EDIT).", + "Show a prompt with Restart / Keep running.", + "Keep running the old code and show a warning with a Restart button." + ], + "description": "What happens when an edit cannot be hot reloaded (a rude edit)." + }, + "dotnetSolution.hotReload.buildSolutionFirst": { + "type": "boolean", + "default": true, + "markdownDescription": "Run the solution build before starting `dotnet watch`. dotnet watch builds only the startup project, so solution-level dependencies (the Editor's post-build step needs the Builder) would otherwise be missing on a clean checkout." + }, + "dotnetSolution.hotReload.watchArgs": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Extra arguments for dotnet watch itself, e.g. [\"--verbose\"]." } } }, @@ -330,6 +444,11 @@ "command": "dotnetSolution.selectStartupProject", "key": "ctrl+alt+p", "when": "dotnetSolution.active" + }, + { + "command": "dotnetSolution.hotReload.start", + "key": "ctrl+alt+f5", + "when": "dotnetSolution.active && !dotnetSolution.hotReload.running" } ] }, diff --git a/src/extension.ts b/src/extension.ts index c34f95c..ee7dca1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,10 +6,12 @@ import { configurationKey, projectConfigurationFor, SolutionConfiguration } from import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks'; import { launchOptionsFor } from './launch'; import { StatusBar } from './status'; +import { HotReloadController } from './hotreload/controller'; let model: SolutionModel; let status: StatusBar; let log: vscode.OutputChannel; +let hotReload: HotReloadController; function settings() { return vscode.workspace.getConfiguration('dotnetSolution'); @@ -303,13 +305,14 @@ export function activate(context: vscode.ExtensionContext): void { log = vscode.window.createOutputChannel('.NET Solution'); model = new SolutionModel(context.workspaceState, log); status = new StatusBar(model); + hotReload = new HotReloadController(model, log, () => build('build')); const active = () => model.active; const command = (id: string, handler: (...args: unknown[]) => unknown) => vscode.commands.registerCommand(id, handler); context.subscriptions.push( - log, model, status, + log, model, status, hotReload, vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)), vscode.debug.registerDebugConfigurationProvider( @@ -328,6 +331,15 @@ export function activate(context: vscode.ExtensionContext): void { command('dotnetSolution.debug', () => launch(false)), command('dotnetSolution.run', () => launch(true)), command('dotnetSolution.generateLaunchConfig', generateLaunchConfig), + + command('dotnetSolution.hotReload.start', () => hotReload.start()), + command('dotnetSolution.hotReload.apply', () => hotReload.apply()), + command('dotnetSolution.hotReload.restart', () => hotReload.restart()), + command('dotnetSolution.hotReload.stop', () => hotReload.stop()), + command('dotnetSolution.hotReload.attach', () => hotReload.attach()), + command('dotnetSolution.hotReload.detach', () => hotReload.detach()), + command('dotnetSolution.hotReload.showTerminal', () => hotReload.showTerminal()), + command('dotnetSolution.hotReload.menu', () => hotReload.menu()), command('dotnetSolution.reload', () => model.reload()), command('dotnetSolution.showOutput', () => log.show()), command('dotnetSolution.setStartupProject', async (resource?: unknown) => { diff --git a/src/hotreload/attach.ts b/src/hotreload/attach.ts new file mode 100644 index 0000000..914c594 --- /dev/null +++ b/src/hotreload/attach.ts @@ -0,0 +1,136 @@ +import * as vscode from 'vscode'; +import { isAlive, waitForApp } from './processes'; +import { HotReloadSession } from './session'; + +/** + * A debugger attached, on request, to the application `dotnet watch` runs. + * + * Deliberately not automatic. The old approach — attach at start and re-attach after + * every restart — meant guessing whether an ended session was a stop or a swap, racing + * the outgoing process, and giving up after crash loops. Here the debugger is attached + * when asked for, and when the process it was on disappears while the watcher is still + * running, that is reported once with an offer to re-attach. + */ +export class DebugLink implements vscode.Disposable { + private session: vscode.DebugSession | undefined; + private attachedPid: number | undefined; + private attaching = false; + private stopping = false; + private readonly subscriptions: vscode.Disposable[] = []; + private readonly changed = new vscode.EventEmitter(); + readonly onDidChange = this.changed.event; + + constructor( + private readonly watch: HotReloadSession, + private readonly debugType: string, + private readonly log: vscode.OutputChannel, + ) { + this.subscriptions.push( + vscode.debug.onDidStartDebugSession(session => this.adopt(session)), + vscode.debug.onDidTerminateDebugSession(session => void this.onTerminated(session)), + ); + } + + get attached(): boolean { return this.session !== undefined; } + get pid(): number | undefined { return this.attachedPid; } + get busy(): boolean { return this.attaching; } + + private get sessionName(): string { + return `Hot Reload: ${this.watch.spec.projectName}`; + } + + /** Finds the application below the watcher and attaches to it. */ + async attach(): Promise { + if (this.attaching || this.session) { + return false; + } + const rootPid = this.watch.pid; + if (rootPid === undefined) { + this.log.appendLine('no pid for dotnet watch, cannot attach'); + return false; + } + + this.attaching = true; + this.changed.fire(); + try { + const target = await waitForApp(rootPid, this.watch.spec.assemblyName, { + cancelled: () => !this.watch.running, + }); + if (!target) { + this.log.appendLine(`no process named ${this.watch.spec.assemblyName} found below pid ${rootPid}`); + return false; + } + this.log.appendLine(`attaching ${this.debugType} to ${target.name} (pid ${target.pid})`); + this.attachedPid = target.pid; + const started = await vscode.debug.startDebugging( + vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this.watch.spec.projectPath)), + { type: this.debugType, request: 'attach', name: this.sessionName, processId: target.pid }, + { suppressSaveBeforeStart: true }); + if (!started) { + this.log.appendLine(`the ${this.debugType} adapter refused to attach`); + this.attachedPid = undefined; + } + return started; + } finally { + this.attaching = false; + this.changed.fire(); + } + } + + async detach(): Promise { + const session = this.session; + if (!session) { + return; + } + this.stopping = true; + try { + await vscode.debug.stopDebugging(session); + } finally { + this.stopping = false; + } + } + + /** Call before tearing the watcher down, so the process dying is not reported. */ + expectShutdown(): void { + this.stopping = true; + } + + private adopt(session: vscode.DebugSession): void { + if (session.name !== this.sessionName || session.configuration?.processId !== this.attachedPid) { + return; + } + this.session = session; + this.changed.fire(); + } + + private async onTerminated(session: vscode.DebugSession): Promise { + if (session !== this.session) { + return; + } + const previousPid = this.attachedPid; + this.session = undefined; + this.attachedPid = undefined; + this.changed.fire(); + + // The adapter reports the session gone slightly before the OS reaps the process. + await new Promise(resolve => setTimeout(resolve, 300)); + if (this.stopping || !this.watch.running || isAlive(previousPid)) { + // A stop, a shutdown, or a deliberate detach: nothing to say. + return; + } + const choice = await vscode.window.showInformationMessage( + `${this.watch.spec.projectName} was restarted by dotnet watch, so the debugger is no longer attached.`, + 'Re-attach'); + if (choice === 'Re-attach' && this.watch.running) { + await this.attach(); + } + } + + dispose(): void { + this.stopping = true; + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + this.changed.dispose(); + } +} diff --git a/src/hotreload/controller.ts b/src/hotreload/controller.ts new file mode 100644 index 0000000..a7a7756 --- /dev/null +++ b/src/hotreload/controller.ts @@ -0,0 +1,280 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { launchOptionsFor } from '../launch'; +import { SolutionModel } from '../model'; +import { DebugLink } from './attach'; +import { HotReloadSession, RudeEditPolicy, SessionSpec } from './session'; +import { LABELS } from './state'; + +function settings() { + return vscode.workspace.getConfiguration('dotnetSolution'); +} + +/** + * Hot reload as a third way to start the startup project, next to Debug and Run. + * + * Same project, configuration, platform, arguments and environment as the other two; + * only the host differs: `dotnet watch run` owns the process, applies saved edits, and + * restarts on rude edits according to `dotnetSolution.hotReload.rudeEdit`. + */ +export class HotReloadController implements vscode.Disposable { + private session: HotReloadSession | undefined; + private link: DebugLink | undefined; + private readonly item: vscode.StatusBarItem; + private readonly subscriptions: vscode.Disposable[] = []; + + constructor( + private readonly model: SolutionModel, + private readonly log: vscode.OutputChannel, + /** Runs the solution build; resolves with the exit code. */ + private readonly buildFirst: () => Promise, + ) { + this.item = vscode.window.createStatusBarItem('dotnetSolution.hotReload', vscode.StatusBarAlignment.Left, 100.3); + this.item.name = '.NET Solution: Hot Reload'; + this.subscriptions.push(this.item, model.onDidChange(() => this.render())); + this.render(); + } + + get running(): boolean { return this.session?.running === true; } + + private setContexts(): void { + void vscode.commands.executeCommand('setContext', 'dotnetSolution.hotReload.running', this.running); + void vscode.commands.executeCommand('setContext', 'dotnetSolution.hotReload.attached', this.link?.attached === true); + } + + render(): void { + const state = this.session?.state ?? 'idle'; + const label = LABELS[state]; + const active = this.model.active; + + if (!active && !this.session) { + this.item.hide(); + this.setContexts(); + return; + } + + const debuggerMark = this.link?.busy ? ' $(loading~spin)' : this.link?.attached ? ' $(debug)' : ''; + this.item.text = `$(${label.icon}) ${label.text}${debuggerMark}`; + this.item.backgroundColor = label.warn ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined; + this.item.command = this.session?.running ? 'dotnetSolution.hotReload.menu' : 'dotnetSolution.hotReload.start'; + + const lines = [label.tooltip]; + if (this.session) { + const { spec } = this.session; + lines.push('', `Project: \`${spec.projectName}\` (${spec.configuration}|${spec.platform})`); + lines.push(this.link?.attached + ? `Debugger: attached (pid ${this.link.pid})` + : this.link?.busy ? 'Debugger: attaching…' : 'Debugger: not attached — click for *Attach Debugger*'); + if (this.session.lastMessage) { + lines.push('', `Last: ${this.session.lastMessage}`); + } + lines.push('', 'Click for apply, restart, attach, stop.'); + } else if (active) { + lines.push('', `Runs \`${active.project.info.name}\` as ${active.projectConfiguration}|${active.projectPlatform} under dotnet watch.`); + } + this.item.tooltip = new vscode.MarkdownString(lines.join(' \n')); + this.item.show(); + this.setContexts(); + } + + private spec(): SessionSpec | undefined { + const active = this.model.active; + if (!active) { + return undefined; + } + const info = active.project.info; + const launch = launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath)); + const hot = settings(); + return { + projectPath: info.fsPath, + projectName: info.name, + assemblyName: info.assemblyName, + configuration: active.projectConfiguration, + platform: active.projectPlatform, + targetFramework: active.targetFramework, + args: launch.args, + env: launch.env, + // dotnet watch runs the app from the project directory unless told otherwise; + // the launch cwd (per-project setting or launchSettings) is what Debug uses too. + cwd: launch.cwd ?? path.dirname(info.fsPath), + watchArgs: hot.get('hotReload.watchArgs', []), + rudeEdit: hot.get('hotReload.rudeEdit', 'restart'), + }; + } + + async start(): Promise { + if (this.session?.running) { + void vscode.window.showInformationMessage( + `Hot reload is already running for ${this.session.spec.projectName}.`); + this.session.showTerminal(); + return; + } + const spec = this.spec(); + if (!spec) { + void vscode.window.showWarningMessage('No startup project selected.'); + return; + } + + // dotnet watch (SDK 10) declines to hot reload an optimised build and restarts the + // application on every change instead. Test and Release here set Optimize, so say + // so before a long session of wondering why nothing applies. + if (!await this.checkOptimize(spec)) { + return; + } + + // dotnet watch only builds the project it runs. Solution-level dependencies + // (Editor's post-build step needs the Builder) are satisfied by a solution build + // first; dotnet watch's own build is then incremental and fast. + if (settings().get('hotReload.buildSolutionFirst', true)) { + if (await this.buildFirst() !== 0) { + return; + } + } + + this.teardown(); + this.session = new HotReloadSession(spec, this.log); + this.session.onDidChangeState(() => this.render()); + this.session.onRestartPrompt(() => void this.onRestartPrompt()); + this.link = new DebugLink(this.session, settings().get('debugType', 'coreclr'), this.log); + this.link.onDidChange(() => this.render()); + this.session.start(); + this.render(); + } + + /** False when the user chose not to continue with an optimised configuration. */ + private async checkOptimize(spec: SessionSpec): Promise { + let optimize = false; + try { + optimize = (await this.model.resolveTarget()).optimize; + } catch (error) { + this.log.appendLine(`could not evaluate Optimize: ${error instanceof Error ? error.message : error}`); + } + if (!optimize) { + return true; + } + const debug = this.model.configurations.find(config => + /^debug$/i.test(config.configuration) && this.model.isNaturalConfiguration(config)); + const choice = await vscode.window.showWarningMessage( + `${spec.configuration}|${spec.platform} builds with Optimize=true, which dotnet watch cannot hot reload — ` + + 'it will restart the application on every change instead.', + ...(debug ? [`Switch to ${debug.configuration} | ${debug.platform}`] : []), 'Start anyway'); + if (choice === undefined) { + return false; + } + if (choice.startsWith('Switch') && debug) { + await this.model.selectConfiguration(debug); + const next = this.spec(); + if (next) { + Object.assign(spec, next); + } + } + return true; + } + + /** What to do when dotnet watch asks whether to restart after a rude edit. */ + private async onRestartPrompt(): Promise { + const session = this.session; + if (!session) { + return; + } + const policy = session.spec.rudeEdit; + if (policy === 'warn') { + session.answerRestartPrompt(false); + const choice = await vscode.window.showWarningMessage( + `${session.spec.projectName}: the last edit cannot be hot reloaded. It keeps running the old code until restarted.`, + 'Restart'); + if (choice === 'Restart') { + session.restart(); + } + return; + } + if (policy === 'ask') { + const choice = await vscode.window.showWarningMessage( + `${session.spec.projectName}: the last edit cannot be hot reloaded. Restart the application?`, + { modal: false }, 'Restart', 'Keep running'); + session.answerRestartPrompt(choice === 'Restart'); + } + // 'restart' never gets here: DOTNET_WATCH_RESTART_ON_RUDE_EDIT answers for us. + } + + async apply(): Promise { + if (!this.session?.running) { + return this.start(); + } + await this.session.apply(); + this.render(); + } + + restart(): void { + this.session?.restart(); + this.render(); + } + + async stop(): Promise { + this.teardown(); + this.session?.dispose(); + this.session = undefined; + this.render(); + } + + async attach(): Promise { + if (!this.session?.running || !this.link) { + void vscode.window.showInformationMessage('No hot reload session is running.'); + return; + } + if (this.link.attached) { + void vscode.window.showInformationMessage(`The debugger is already attached to ${this.session.spec.projectName}.`); + return; + } + const ok = await this.link.attach(); + if (!ok && this.session.running) { + void vscode.window.showWarningMessage( + `Could not attach to ${this.session.spec.assemblyName}. Is it running yet? See the log.`, + 'Show Log').then(choice => choice && this.log.show()); + } + } + + async detach(): Promise { + await this.link?.detach(); + } + + showTerminal(): void { + this.session?.showTerminal(); + } + + /** The click target while a session runs: one place for every action. */ + async menu(): Promise { + if (!this.session?.running) { + return this.start(); + } + const attached = this.link?.attached === true; + type Item = vscode.QuickPickItem & { run: () => unknown }; + const items: Item[] = [ + { label: '$(flame) Apply changes', description: 'save all files; dotnet watch picks them up', run: () => this.apply() }, + { label: '$(debug-restart) Restart application', description: 'Ctrl+R in the watch terminal', run: () => this.restart() }, + attached + ? { label: '$(debug-disconnect) Detach debugger', run: () => this.detach() } + : { label: '$(debug) Attach debugger', description: 'breakpoints on demand', run: () => this.attach() }, + { label: '$(terminal) Show terminal', run: () => this.showTerminal() }, + { label: '$(debug-stop) Stop hot reload', run: () => this.stop() }, + ]; + const picked = await vscode.window.showQuickPick(items, { + title: `Hot Reload: ${this.session.spec.projectName} — ${LABELS[this.session.state].text}`, + }); + await picked?.run(); + } + + private teardown(): void { + this.link?.expectShutdown(); + this.link?.dispose(); + this.link = undefined; + } + + dispose(): void { + this.teardown(); + this.session?.dispose(); + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } +} diff --git a/src/hotreload/processes.ts b/src/hotreload/processes.ts new file mode 100644 index 0000000..1f62430 --- /dev/null +++ b/src/hotreload/processes.ts @@ -0,0 +1,181 @@ +import { spawn } from 'child_process'; + +/** A process, as far as attaching needs it. */ +export interface ProcessInfo { + pid: number; + parentPid: number; + name: string; +} + +/** + * Every process on the machine, or an empty list if none of the tools work. + * + * `wmic` is fast and present on Windows 10, but removed from recent Windows 11 builds, + * so PowerShell's CIM query is the fallback there. + */ +export async function listProcesses(): Promise { + if (process.platform !== 'win32') { + try { + return parsePs(await capture('ps', ['-eo', 'pid=,ppid=,comm='])); + } catch { + return []; + } + } + try { + const out = await capture('wmic', ['process', 'get', 'ProcessId,ParentProcessId,Name', '/format:csv']); + const parsed = parseWmicCsv(out); + if (parsed.length > 0) { + return parsed; + } + } catch { + // fall through + } + try { + const out = await capture('powershell', [ + '-NoProfile', '-NonInteractive', '-Command', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation', + ]); + return parseWmicCsv(out.replace(/"/g, '')); + } catch { + return []; + } +} + +export function parseWmicCsv(text: string): ProcessInfo[] { + const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + const header = lines.findIndex(line => /(^|,)Name(,|$)/i.test(line)); + if (header < 0) { + return []; + } + const columns = lines[header].split(',').map(column => column.trim().toLowerCase()); + const nameAt = columns.indexOf('name'); + const parentAt = columns.indexOf('parentprocessid'); + const pidAt = columns.indexOf('processid'); + if (nameAt < 0 || parentAt < 0 || pidAt < 0) { + return []; + } + + const processes: ProcessInfo[] = []; + for (const line of lines.slice(header + 1)) { + const cells = line.split(','); + const pid = Number(cells[pidAt]); + const parentPid = Number(cells[parentAt]); + if (Number.isFinite(pid) && Number.isFinite(parentPid)) { + processes.push({ pid, parentPid, name: (cells[nameAt] ?? '').trim() }); + } + } + return processes; +} + +export function parsePs(text: string): ProcessInfo[] { + const processes: ProcessInfo[] = []; + for (const line of text.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line); + if (match) { + processes.push({ pid: Number(match[1]), parentPid: Number(match[2]), name: match[3] }); + } + } + return processes; +} + +/** + * The application process below `rootPid`, identified by assembly name. + * + * `dotnet watch` sits in the middle: it spawns a build and then the application, so the + * target is a descendant rather than a direct child. Matching on the assembly name keeps + * us from attaching to MSBuild. The most recently listed match is the newest one, which + * matters right after a restart when the old process is still shutting down. + */ +export function findAppProcess( + processes: readonly ProcessInfo[], rootPid: number, assembly: string, avoidPid?: number, +): ProcessInfo | undefined { + const byParent = new Map(); + for (const info of processes) { + const siblings = byParent.get(info.parentPid) ?? []; + siblings.push(info); + byParent.set(info.parentPid, siblings); + } + + const wanted = assembly.toLowerCase(); + const candidates: ProcessInfo[] = []; + const queue = [rootPid]; + const seen = new Set([rootPid]); + + while (queue.length > 0) { + const pid = queue.shift()!; + for (const child of byParent.get(pid) ?? []) { + if (seen.has(child.pid)) { + continue; + } + seen.add(child.pid); + queue.push(child.pid); + + const name = child.name.toLowerCase().replace(/\.exe$/, ''); + if (name === wanted && child.pid !== avoidPid) { + candidates.push(child); + } + } + } + return candidates[candidates.length - 1]; +} + +/** Whether a pid is still running. EPERM means it exists but is not ours, which counts. */ +export function isAlive(pid: number | undefined): boolean { + if (pid === undefined) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** Polls for the application process below the watcher; the build has to finish first. */ +export async function waitForApp( + rootPid: number, assembly: string, + options: { timeoutMs?: number; intervalMs?: number; avoidPid?: number; cancelled?: () => boolean } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 90_000); + while (Date.now() < deadline && !options.cancelled?.()) { + const found = findAppProcess(await listProcesses(), rootPid, assembly, options.avoidPid); + if (found) { + return found; + } + await new Promise(resolve => setTimeout(resolve, options.intervalMs ?? 500)); + } + return undefined; +} + +/** + * Kills the watcher and the application it launched. + * + * `child.kill()` only signals `dotnet watch` itself, leaving the application running and + * holding its output files — so the whole tree has to go. + */ +export async function killTree(pid: number): Promise { + try { + if (process.platform === 'win32') { + await capture('taskkill', ['/PID', String(pid), '/T', '/F']); + } else { + process.kill(-pid, 'SIGTERM'); + } + } catch { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } +} + +function capture(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { windowsHide: true }); + let out = ''; + child.stdout?.on('data', chunk => (out += String(chunk))); + child.on('error', reject); + child.on('exit', () => resolve(out)); + }); +} diff --git a/src/hotreload/session.ts b/src/hotreload/session.ts new file mode 100644 index 0000000..c4b2f72 --- /dev/null +++ b/src/hotreload/session.ts @@ -0,0 +1,212 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ChildProcess, spawn } from 'child_process'; +import { dotnetPath } from '../msbuild'; +import { killTree } from './processes'; +import { classify, isRestartPrompt, summarize, WatchState } from './state'; + +export type RudeEditPolicy = 'restart' | 'ask' | 'warn'; + +/** Everything a session needs, resolved by the controller from the status bar selection. */ +export interface SessionSpec { + projectPath: string; + projectName: string; + /** The process to look for when attaching; usually the project name. */ + assemblyName: string; + configuration: string; + /** MSBuild spelling (AnyCPU). */ + platform: string; + targetFramework?: string; + args: string[]; + env: Record; + cwd: string; + watchArgs: string[]; + rudeEdit: RudeEditPolicy; +} + +/** + * A `dotnet watch run` session hosted in a VS Code pseudoterminal. + * + * A pseudoterminal rather than a task because the same stream has to be shown, parsed + * for state, and fed with keystrokes so `dotnet watch`'s own keys (Ctrl+R restart) keep + * working. The application runs *without* a debugger; attaching one is a separate, + * explicit step handled by DebugLink. + */ +export class HotReloadSession implements vscode.Disposable { + private child: ChildProcess | undefined; + private terminal: vscode.Terminal | undefined; + private readonly write = new vscode.EventEmitter(); + private readonly closed = new vscode.EventEmitter(); + private readonly stateChanged = new vscode.EventEmitter(); + private readonly promptSeen = new vscode.EventEmitter(); + readonly onDidChangeState = this.stateChanged.event; + /** Fires when dotnet watch asks whether to restart after a rude edit. */ + readonly onRestartPrompt = this.promptSeen.event; + + private currentState: WatchState = 'idle'; + private pending = ''; + private message = ''; + + constructor(readonly spec: SessionSpec, private readonly log: vscode.OutputChannel) { } + + get state(): WatchState { return this.currentState; } + get lastMessage(): string { return this.message; } + get pid(): number | undefined { return this.child?.pid; } + get running(): boolean { return this.child !== undefined && this.child.exitCode === null; } + + start(): void { + const { spec } = this; + const args = [ + 'watch', 'run', + '--project', spec.projectPath, + '-c', spec.configuration, + // Not -p: — dotnet watch takes -p as its own --project alias. + `--property:Platform=${spec.platform}`, + ...(spec.targetFramework ? ['-f', spec.targetFramework] : []), + ...spec.watchArgs, + ...(spec.args.length > 0 ? ['--', ...spec.args] : []), + ]; + + const pty: vscode.Pseudoterminal = { + onDidWrite: this.write.event, + onDidClose: this.closed.event, + open: () => this.spawn(args), + close: () => void this.stop(), + handleInput: data => this.child?.stdin?.write(data), + }; + + this.terminal = vscode.window.createTerminal({ + name: `Hot Reload: ${spec.projectName} (${spec.configuration}|${spec.platform})`, + pty, + iconPath: new vscode.ThemeIcon('flame'), + }); + this.terminal.show(true); + } + + showTerminal(): void { + this.terminal?.show(false); + } + + private spawn(args: string[]): void { + const { spec } = this; + this.setState('starting'); + const command = dotnetPath(); + this.log.appendLine(`> ${command} ${args.join(' ')} (cwd ${spec.cwd})`); + + const env: Record = { + ...process.env, + ...spec.env, + // Colour and emoji make the terminal readable; the classifier strips them. + DOTNET_WATCH_SUPPRESS_EMOJIS: process.env.DOTNET_WATCH_SUPPRESS_EMOJIS ?? '0', + }; + // With 'restart' dotnet watch restarts on its own and never asks. The other two + // policies leave the question to us: the prompt line is intercepted in consume(). + if (spec.rudeEdit === 'restart') { + env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true'; + } else { + delete env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT; + } + + this.child = spawn(command, args, { + cwd: spec.cwd, + env, + windowsHide: true, + }); + + this.child.stdout?.on('data', chunk => this.consume(String(chunk))); + this.child.stderr?.on('data', chunk => this.consume(String(chunk))); + this.child.on('error', error => { + this.log.appendLine(`failed to start: ${error.message}`); + this.write.fire(`\r\n\x1b[31mFailed to start dotnet watch: ${error.message}\x1b[0m\r\n`); + this.setState('exited'); + }); + this.child.on('exit', code => { + this.log.appendLine(`dotnet watch exited with code ${code ?? 0}`); + this.setState('exited'); + this.closed.fire(code ?? 0); + }); + } + + /** Mirrors output to the terminal and reads state out of the same stream. */ + private consume(chunk: string): void { + this.write.fire(chunk.replace(/\r?\n/g, '\r\n')); + + this.pending += chunk; + const lines = this.pending.split(/\r?\n/); + this.pending = lines.pop() ?? ''; + // The restart question has no newline after it; look at the partial line too. + if (this.pending && isRestartPrompt(this.pending)) { + lines.push(this.pending); + this.pending = ''; + } + for (const line of lines) { + if (line.trim().length === 0) { + continue; + } + this.log.appendLine(line); + const state = classify(line); + if (state) { + this.message = summarize(line); + this.setState(state); + } + if (isRestartPrompt(line)) { + this.promptSeen.fire(); + } + } + } + + private setState(state: WatchState): void { + this.currentState = state; + this.stateChanged.fire(state); + } + + /** + * Applies pending edits by saving them: `dotnet watch` watches the file system, so an + * unsaved buffer is invisible to it. That is the honest meaning of an "apply" button. + */ + async apply(): Promise { + const dirty = vscode.workspace.textDocuments.filter(doc => doc.isDirty); + if (dirty.length === 0) { + this.message = 'nothing to apply — no unsaved changes'; + this.stateChanged.fire(this.currentState); + return false; + } + await vscode.workspace.saveAll(false); + this.log.appendLine(`saved ${dirty.length} file(s) to trigger hot reload`); + return true; + } + + /** Answers the rude-edit question: yes restarts, no keeps the old code running. */ + answerRestartPrompt(restart: boolean): void { + this.child?.stdin?.write(restart ? 'y' : 'n'); + this.log.appendLine(`answered the restart prompt with ${restart ? 'yes' : 'no'}`); + } + + /** Restarts the watched application without restarting the watcher (Ctrl+R). */ + restart(): void { + this.child?.stdin?.write('\x12'); + this.log.appendLine('requested a restart (Ctrl+R)'); + } + + async stop(): Promise { + const child = this.child; + this.child = undefined; + if (child?.pid && child.exitCode === null) { + await killTree(child.pid); + } + this.setState('idle'); + } + + dispose(): void { + void this.stop(); + this.terminal?.dispose(); + this.write.dispose(); + this.closed.dispose(); + this.stateChanged.dispose(); + this.promptSeen.dispose(); + } +} + +export function defaultCwd(projectPath: string): string { + return path.dirname(projectPath); +} diff --git a/src/hotreload/state.ts b/src/hotreload/state.ts new file mode 100644 index 0000000..ad20e62 --- /dev/null +++ b/src/hotreload/state.ts @@ -0,0 +1,110 @@ +/** + * What `dotnet watch` is currently doing, as far as its output tells us. + * + * The output format is not a contract — the emoji and wording have changed between SDK + * releases — so the parser matches on keywords rather than exact strings, and everything + * it cannot classify is still written to the log for diagnosis. + */ +export type WatchState = + | 'idle' + | 'starting' + | 'running' + | 'applied' + | 'failed' + | 'restartRequired' + | 'exited'; + +export interface StateLabel { + /** Codicon id for the status bar. */ + icon: string; + text: string; + tooltip: string; + /** True when the state deserves the warning colour. */ + warn?: boolean; +} + +export const LABELS: Record = { + idle: { + icon: 'flame', + text: 'Hot Reload', + tooltip: 'Run the startup project under dotnet watch with hot reload', + }, + starting: { + icon: 'loading~spin', + text: 'Hot Reload: starting', + tooltip: 'dotnet watch is building and launching the application', + }, + running: { + icon: 'flame', + text: 'Hot Reload: watching', + tooltip: 'dotnet watch is watching for changes. Save a file to apply it.', + }, + applied: { + icon: 'flame', + text: 'Hot Reload: applied', + tooltip: 'The last change was applied to the running application', + }, + failed: { + icon: 'warning', + text: 'Hot Reload: failed', + tooltip: 'The last change could not be applied. See the terminal.', + warn: true, + }, + restartRequired: { + icon: 'debug-restart', + text: 'Hot Reload: restart needed', + tooltip: 'The change cannot be hot reloaded — a restart is needed to apply it', + warn: true, + }, + exited: { + icon: 'circle-slash', + text: 'Hot Reload: exited', + tooltip: 'The watched application exited', + }, +}; + +/** True for the console question dotnet watch asks after a rude edit. */ +export function isRestartPrompt(line: string): boolean { + return /do you want to restart/i.test(line); +} + +/** + * Classifies one line of `dotnet watch` output. + * + * Returns undefined for lines that say nothing about state, which is most of them — + * the application's own stdout flows through here too. + */ +export function classify(line: string): WatchState | undefined { + const text = line.toLowerCase(); + + // Order matters: a failure mentioning "hot reload" must not read as a success. + if (isRestartPrompt(text) || /restart(?:\s+is)?\s+(?:needed|required)|rude edit/.test(text)) { + return 'restartRequired'; + } + if (/hot reload/.test(text) && /fail|error|unable|could not/.test(text)) { + return 'failed'; + } + if (/hot reload/.test(text) && /succeed|applied|handled/.test(text)) { + return 'applied'; + } + if (/waiting for (?:a )?file(?: to change)?|waiting for changes|no hot reload changes to apply/.test(text)) { + return 'running'; + } + if (/started|now listening on|hot reload enabled/.test(text)) { + return 'running'; + } + if (/exited|shutdown requested|process terminated/.test(text)) { + return 'exited'; + } + return undefined; +} + +/** Strips the `dotnet watch` prefix and its emoji, for a one-line status summary. */ +export function summarize(line: string): string { + return line + .replace(/^\s*dotnet watch\s*/i, '') + // dotnet watch decorates its messages with emoji (🔥 ⌚ ❌ ⏳). + .replace(/[\p{Extended_Pictographic}️]/gu, '') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/msbuild.ts b/src/msbuild.ts index 0fb11e3..a08da3c 100644 --- a/src/msbuild.ts +++ b/src/msbuild.ts @@ -12,6 +12,8 @@ export interface TargetInfo { executablePath: string | undefined; /** The framework the evaluation used, when the project multi-targets. */ targetFramework: string | undefined; + /** True when the configuration optimises, which dotnet watch refuses to hot reload. */ + optimize: boolean; } export interface EvaluationRequest { @@ -22,7 +24,7 @@ export interface EvaluationRequest { targetFramework?: string; } -const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt']; +const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt', 'Optimize']; export function dotnetPath(): string { // DotRush has its own SDK directory setting; honour it so both agree on the SDK. @@ -94,5 +96,6 @@ export async function evaluateTarget(request: EvaluationRequest, log: vscode.Out ? path.join(targetDir, assemblyName + (process.platform === 'win32' ? '.exe' : '')) : undefined; - return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework }; + const optimize = (properties.Optimize ?? 'false').trim().toLowerCase() === 'true'; + return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework, optimize }; } diff --git a/src/test/unit/hotreload.test.ts b/src/test/unit/hotreload.test.ts new file mode 100644 index 0000000..369ba2f --- /dev/null +++ b/src/test/unit/hotreload.test.ts @@ -0,0 +1,50 @@ +import * as assert from 'assert'; +import { classify, isRestartPrompt, summarize } from '../../hotreload/state'; +import { findAppProcess, parsePs, parseWmicCsv } from '../../hotreload/processes'; + +suite('hot reload output classifier', () => { + test('maps dotnet watch lines to states', () => { + assert.strictEqual(classify('dotnet watch ⌚ Waiting for a file to change before restarting dotnet...'), 'running'); + assert.strictEqual(classify('dotnet watch 🔥 Hot reload of changes succeeded.'), 'applied'); + assert.strictEqual(classify('dotnet watch ❌ Unable to apply hot reload because of a rude edit.'), 'restartRequired'); + assert.strictEqual(classify('dotnet watch ❌ Hot reload failed: compilation errors'), 'failed'); + assert.strictEqual(classify('dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see ...'), 'running'); + assert.strictEqual(classify(' Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?'), 'restartRequired'); + assert.strictEqual(classify('dotnet watch ⌚ Waiting for changes'), 'running'); + assert.strictEqual(classify('info: Program[0] Frame 1234'), undefined); + }); + + test('recognises the restart prompt without a newline', () => { + assert.ok(isRestartPrompt('Do you want to restart your app - Yes (y) / No (n)')); + assert.ok(!isRestartPrompt('restarting dotnet...')); + }); + + test('summarize strips prefix and emoji', () => { + assert.strictEqual(summarize('dotnet watch 🔥 Hot reload of changes succeeded.'), 'Hot reload of changes succeeded.'); + }); +}); + +suite('process lookup', () => { + const processes = [ + { pid: 1, parentPid: 0, name: 'System' }, + { pid: 100, parentPid: 1, name: 'dotnet.exe' }, // dotnet watch + { pid: 101, parentPid: 100, name: 'MSBuild.exe' }, + { pid: 102, parentPid: 100, name: 'MyGame.Editor.exe' }, // old, exiting + { pid: 103, parentPid: 100, name: 'MyGame.Editor.exe' }, // new + { pid: 200, parentPid: 1, name: 'MyGame.Editor.exe' }, // unrelated instance + ]; + + test('finds the newest matching descendant of the watcher', () => { + assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Editor')?.pid, 103); + assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Editor', 103)?.pid, 102); + assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Builder'), undefined); + }); + + test('parses wmic csv and ps output', () => { + const csv = '\r\nNode,Name,ParentProcessId,ProcessId\r\nPC,dotnet.exe,1,100\r\nPC,MyGame.Editor.exe,100,103\r\n'; + assert.deepStrictEqual(parseWmicCsv(csv), [ + { pid: 100, parentPid: 1, name: 'dotnet.exe' }, { pid: 103, parentPid: 100, name: 'MyGame.Editor.exe' }]); + assert.deepStrictEqual(parsePs(' 100 1 dotnet\n 103 100 MyGame.Editor\n'), [ + { pid: 100, parentPid: 1, name: 'dotnet' }, { pid: 103, parentPid: 100, name: 'MyGame.Editor' }]); + }); +});