From 9bd1b8eea0092b352c752307e720d6f5ef1ac92a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 08:45:44 +0300 Subject: [PATCH] Skip MSBuild on run-aspire.cmd when binaries are still newer than sources. dotnet run always evaluates the Aspire graph. A second launch after Ctrl+C should not wait on that. Co-authored-by: Cursor --- AGENTS.md | 18 +- README.md | 3 +- docs/design/off-queue.md | 34 ++++ docs/phases/49-skip-stale-build.md | 45 +++++ docs/phases/README.md | 8 + run-aspire.cmd | 67 +++++++- .../HSchool.AppHost.Tests/LaunchBuildStamp.cs | 105 ++++++++++++ .../LaunchBuildStampTests.cs | 157 ++++++++++++++++++ tools/apphost-uptodate.ps1 | 80 +++++++++ 9 files changed, 504 insertions(+), 13 deletions(-) create mode 100644 docs/design/off-queue.md create mode 100644 docs/phases/49-skip-stale-build.md create mode 100644 tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs create mode 100644 tests/HSchool.AppHost.Tests/LaunchBuildStampTests.cs create mode 100644 tools/apphost-uptodate.ps1 diff --git a/AGENTS.md b/AGENTS.md index f9adb9c..632f236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,12 +48,14 @@ npm --prefix src/HSchool.Client run build dotnet run --project src/HSchool.AppHost ``` -`run-aspire.cmd` is the same command for Windows users who want a double-clickable entry point — -keep the two in sync if the AppHost path ever moves. +`run-aspire.cmd` is the Windows entry point: it skips MSBuild when AppHost and Server dlls are +newer than C# / csproj / props. Pass `--rebuild` to force a build. Keep the script in sync with +`tools/apphost-uptodate.ps1` if the AppHost path ever moves. -`dotnet run --project src/HSchool.AppHost` starts the server *and* the Vite dev server and opens -the Aspire dashboard. The Vite port is assigned per run (`npm run dev -- --port `), so read -the client URL off the dashboard instead of assuming 5173. +`dotnet run --project src/HSchool.AppHost` still compiles every time — that is for a dirty tree, +not a fast relaunch. It starts the server *and* the Vite dev server and opens the Aspire +dashboard. The Vite port is assigned per run (`npm run dev -- --port `), so read the +client URL off the dashboard instead of assuming 5173. Do not start a dev server with a bare `npm run dev` when you meant to run the whole app — the client only finds the backend through the Aspire-injected `SERVER_HTTP` environment variable, or @@ -130,6 +132,8 @@ say so explicitly in the change description. is empty. Reset deletes through the API, which deletes the save files. Headless AppHost sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing the shared fixture. +- Launch-script up-to-date checks belong in `tests/HSchool.AppHost.Tests` and must not use + `AppHostFixture`. Keep `LaunchBuildStamp` and `tools/apphost-uptodate.ps1` in sync. - Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries @@ -184,3 +188,7 @@ say so explicitly in the change description. the clock does not advance, so occupancy (often empty) and lesson labels from the timetable keep going out. Opening sends one extra snapshot. A test that waits for *people* on pause will hang; a test that waits for a lesson label will not. +- **`dotnet run` on the AppHost always evaluates MSBuild**, even when nothing changed. + `run-aspire.cmd` skips that with `--no-build` when AppHost and Server dlls are newer than + C# / csproj / props. `--rebuild` forces a build. Bare `dotnet run --project src/HSchool.AppHost` + still compiles. diff --git a/README.md b/README.md index 108c820..3c6015f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ dotnet run --project src/HSchool.AppHost On Windows `run-aspire.cmd` does the same and can be double-clicked; it checks that the .NET SDK and Node are on PATH first and passes any arguments through -(`run-aspire.cmd --launch-profile http`). +(`run-aspire.cmd --launch-profile http`). A second launch with no C# changes skips MSBuild; +`run-aspire.cmd --rebuild` forces a build. The Aspire dashboard opens with two resources: `server` (ASP.NET Core) and `client` (Vite dev server). Aspire assigns the client a random port on every run, so take its URL from the dashboard diff --git a/docs/design/off-queue.md b/docs/design/off-queue.md new file mode 100644 index 0000000..acb5a6f --- /dev/null +++ b/docs/design/off-queue.md @@ -0,0 +1,34 @@ +# Вне очереди + +Задачи, которых нет в срезах 1–9. Это срез без номера, чтобы не спорить со срезом 10. +Каждый раздел — отдельная договорённость; фазы живут в индексе в секции «Вне очереди». + +## Пропуск сборки при запуске + +### Зачем + +`run-aspire.cmd` каждый раз вызывает `dotnet run`, а тот — MSBuild графа Aspire, даже когда +исходники не менялись. Повторный запуск после Ctrl+C стоит десятки секунд впустую: SDK +пересчитывает хост, хотя dll на месте. + +### Было / Стало / Почему + +**Было.** Скрипт всегда `dotnet run --project src/HSchool.AppHost`. Аргументы проходили насквозь. + +**Стало.** Если `HSchool.AppHost.dll` и `HSchool.Server.dll` новее входов (C#, csproj, props, +`global.json`), скрипт делает `dotnet run --no-build`. Иначе — `dotnet build`, потом `--no-build`. +`--rebuild` собирает всегда. Конфигурация (`-c` / `--configuration`) выбирает, какие dll сравнивать; +по умолчанию Debug, как у `dotnet run`. + +**Почему.** Инкрементальный MSBuild всё равно оценивает Aspire SDK. `--no-build` это пропускает. +Клиентский TypeScript и JSONC модов не входы: Vite и каталог читают диск (`ContentRoot`), а не +`bin`. Тестовые проекты AppHost не ссылается — их правки запуск не пересобирают. + +Правила «устарел ли билд» живут в `tools/apphost-uptodate.ps1` (то, что вызывает `.cmd`) и в +`LaunchBuildStamp` (то, что проверяют тесты). Менять надо оба. + +### Что не входит + +Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с +грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не +трогаем. diff --git a/docs/phases/49-skip-stale-build.md b/docs/phases/49-skip-stale-build.md new file mode 100644 index 0000000..a20b45b --- /dev/null +++ b/docs/phases/49-skip-stale-build.md @@ -0,0 +1,45 @@ +# Фаза 49. Пропуск сборки при запуске + +## Зависимости + +Нет. Скрипт запуска уже есть. + +## Зачем + +Повторный `run-aspire.cmd` без правок в C# не должен ждать MSBuild графа Aspire. Сборка нужна +только когда входы новее dll или когда просили `--rebuild`. + +## Задачи + +- [x] `tools/apphost-uptodate.ps1`: выход 0, если AppHost и Server dll новее входов; 1 — если + нет dll или вход новее. Входы: `*.cs` / `*.csproj` / `*.props` под `src/` без `bin` / + `obj` / `node_modules`, плюс `global.json`, `Directory.Build.props`, `Directory.Packages.props`. + TypeScript и JSONC не входы +- [x] `run-aspire.cmd` вызывает проверку; при 0 — `dotnet run --no-build` и пишет, что сборка + пропущена; иначе `dotnet build`, затем `--no-build`. `--rebuild` всегда собирает и не + уходит в AppHost. `-c` / `--configuration` выбирают папку `bin` +- [x] `LaunchBuildStamp` с теми же правилами, тесты на временных папках без `AppHostFixture` +- [x] `AGENTS.md` и `README.md`: повторный запуск без правок — `run-aspire.cmd`; голый + `dotnet run` по-прежнему собирает. `--rebuild` упомянут + +## Тесты, без которых фаза не закрыта + +- [x] Нет dll — нужно собирать +- [x] Dll новее всех входов — пропускать +- [x] `.cs` новее dll — собирать +- [x] `.ts` или `.jsonc` новее dll — пропускать +- [x] Файл в `bin` / `obj` / `node_modules` не считается входом +- [x] `Directory.Packages.props` новее dll — собирать +- [x] `-c Release` смотрит `bin/Release`, а не Debug + +## Критерий готовности + +- Два запуска `run-aspire.cmd` подряд без правок: второй пишет, что сборка пропущена, и + стартует AppHost +- `run-aspire.cmd --rebuild` собирает даже со свежими dll +- `dotnet test` проходит; тесты штампа не поднимают хост + +## Стоп + +Не менять Aspire-граф, протокол, сейвы. Не вшивать `--no-build` в голый `dotnet run`. Не +сканировать `tests/` и не считать клиентский TS поводом пересобрать сервер. diff --git a/docs/phases/README.md b/docs/phases/README.md index 14f4b69..374a0f4 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -222,3 +222,11 @@ | [47. Пак romance](47-romance-pack.md) | ⬜ | Ориентация, симпатия, пара 18+, патч тем | 46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать. + +## Вне очереди + +Не срез 10: задачи вне игровых срезов. Дизайн: [`../design/off-queue.md`](../design/off-queue.md). + +| Фаза | Статус | Зачем | +| --- | --- | --- | +| [49. Пропуск сборки при запуске](49-skip-stale-build.md) | 🔄 | `run-aspire.cmd` не гоняет MSBuild, если dll свежие | diff --git a/run-aspire.cmd b/run-aspire.cmd index 9fceda7..cb97a5b 100644 --- a/run-aspire.cmd +++ b/run-aspire.cmd @@ -1,8 +1,10 @@ @echo off -setlocal +setlocal EnableDelayedExpansion rem Starts the whole app: game server, Vite client and the Aspire dashboard. -rem Arguments are passed through, e.g. run-aspire.cmd --launch-profile http +rem Skips MSBuild when AppHost and Server dlls are newer than C# / csproj / props. +rem run-aspire.cmd --launch-profile http +rem run-aspire.cmd --rebuild cd /d "%~dp0" @@ -21,19 +23,70 @@ if errorlevel 1 ( echo. ) +set "CONFIG=Debug" +set "REBUILD=0" +set "ARGS=" + +:parse +if "%~1"=="" goto parsed +if /I "%~1"=="--rebuild" ( + set "REBUILD=1" + shift + goto parse +) +if /I "%~1"=="-c" if not "%~2"=="" set "CONFIG=%~2" +if /I "%~1"=="--configuration" if not "%~2"=="" set "CONFIG=%~2" +set "ARGS=!ARGS! %1" +shift +goto parse + +:parsed + +if "%REBUILD%"=="1" ( + echo [run-aspire] --rebuild: building. + goto build +) + +where powershell >nul 2>&1 +if errorlevel 1 ( + echo [run-aspire] PowerShell not found, building. + goto build +) + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\apphost-uptodate.ps1" -RepoRoot "%CD%" -Configuration "%CONFIG%" +if errorlevel 1 ( + echo [run-aspire] sources changed or binaries missing, building. + goto build +) + +echo [run-aspire] binaries are up to date, skipping build. +goto run + +:build +echo. +dotnet build "src\HSchool.AppHost\HSchool.AppHost.csproj" -c "%CONFIG%" +if errorlevel 1 ( + echo. + echo [run-aspire] build failed. + call :maybe_pause + exit /b 1 +) +echo. + +:run echo [run-aspire] Starting the Aspire AppHost. Press Ctrl+C to shut everything down. echo. -dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" %* -set "EXITCODE=%ERRORLEVEL%" +dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" --no-build --no-restore !ARGS! +set "EXITCODE=!ERRORLEVEL!" -if not "%EXITCODE%"=="0" ( +if not "!EXITCODE!"=="0" ( echo. - echo [run-aspire] AppHost exited with code %EXITCODE%. + echo [run-aspire] AppHost exited with code !EXITCODE!. ) call :maybe_pause -endlocal & exit /b %EXITCODE% +exit /b !EXITCODE! rem Keeps the window open when the file was double-clicked from Explorer. :maybe_pause diff --git a/tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs b/tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs new file mode 100644 index 0000000..40a1182 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs @@ -0,0 +1,105 @@ +namespace HSchool.AppHost.Tests; + +/// +/// Same rules as tools/apphost-uptodate.ps1. Keep the two in sync. +/// +internal static class LaunchBuildStamp +{ + public const string TargetFramework = "net10.0"; + + public static readonly string[] InputExtensions = [".cs", ".csproj", ".props"]; + + public static readonly string[] NamedInputs = + [ + "global.json", + "Directory.Build.props", + "Directory.Packages.props", + ]; + + public static readonly string[] SkipDirectoryNames = ["bin", "obj", "node_modules"]; + + public static readonly string[] OutputProjects = ["HSchool.AppHost", "HSchool.Server"]; + + public static bool NeedsBuild(string repoRoot, string configuration = "Debug") + { + ArgumentException.ThrowIfNullOrWhiteSpace(repoRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(configuration); + + DateTime? oldestOutput = null; + foreach (var project in OutputProjects) + { + var dll = Path.Combine( + repoRoot, + "src", + project, + "bin", + configuration, + TargetFramework, + project + ".dll"); + if (!File.Exists(dll)) + { + return true; + } + + var written = File.GetLastWriteTimeUtc(dll); + if (oldestOutput is null || written < oldestOutput.Value) + { + oldestOutput = written; + } + } + + if (oldestOutput is null) + { + return true; + } + + var cutoff = oldestOutput.Value; + + foreach (var name in NamedInputs) + { + var path = Path.Combine(repoRoot, name); + if (File.Exists(path) && File.GetLastWriteTimeUtc(path) > cutoff) + { + return true; + } + } + + var src = Path.Combine(repoRoot, "src"); + return !Directory.Exists(src) || HasNewerInput(src, cutoff); + } + + private static bool HasNewerInput(string directory, DateTime cutoff) + { + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (!IsInputExtension(Path.GetExtension(file))) + { + continue; + } + + if (File.GetLastWriteTimeUtc(file) > cutoff) + { + return true; + } + } + + foreach (var child in Directory.EnumerateDirectories(directory)) + { + var leaf = Path.GetFileName(child); + if (SkipDirectoryNames.Contains(leaf, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + if (HasNewerInput(child, cutoff)) + { + return true; + } + } + + return false; + } + + private static bool IsInputExtension(string extension) => + InputExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); +} diff --git a/tests/HSchool.AppHost.Tests/LaunchBuildStampTests.cs b/tests/HSchool.AppHost.Tests/LaunchBuildStampTests.cs new file mode 100644 index 0000000..7aada2d --- /dev/null +++ b/tests/HSchool.AppHost.Tests/LaunchBuildStampTests.cs @@ -0,0 +1,157 @@ +namespace HSchool.AppHost.Tests; + +public class LaunchBuildStampTests +{ + private static readonly DateTime Inputs = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Outputs = new(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc); + private static readonly DateTime Newer = new(2026, 1, 1, 12, 10, 0, DateTimeKind.Utc); + + [Fact] + public void MissingDll_NeedsBuild() + { + using var repo = FreshRepo(); + File.Delete(repo.Dll("HSchool.Server")); + + Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void FreshDlls_SkipBuild() + { + using var repo = FreshRepo(); + + Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void NewerCs_NeedsBuild() + { + using var repo = FreshRepo(); + repo.Touch("src/HSchool.Server/Program.cs", Newer); + + Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void NewerTsOrJsonc_SkipBuild() + { + using var repo = FreshRepo(); + repo.Write("src/HSchool.Client/src/main.ts", "export {}"); + repo.Write("src/HSchool.Server/mods/core/defs/foo.jsonc", "{}"); + repo.Touch("src/HSchool.Client/src/main.ts", Newer); + repo.Touch("src/HSchool.Server/mods/core/defs/foo.jsonc", Newer); + + Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void FilesInBinObjNodeModules_AreNotInputs() + { + using var repo = FreshRepo(); + repo.Write("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", "class G {}"); + repo.Write("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", "class G {}"); + repo.Write("src/HSchool.Client/node_modules/pkg/index.cs", "class N {}"); + repo.Touch("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", Newer); + repo.Touch("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", Newer); + repo.Touch("src/HSchool.Client/node_modules/pkg/index.cs", Newer); + + Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void NewerDirectoryPackagesProps_NeedsBuild() + { + using var repo = FreshRepo(); + repo.Touch("Directory.Packages.props", Newer); + + Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root)); + } + + [Fact] + public void ReleaseLooksAtReleaseBin() + { + using var repo = FreshRepo(); + + Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Debug")); + Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root, "Release")); + + repo.WriteDll("HSchool.AppHost", "Release"); + repo.WriteDll("HSchool.Server", "Release"); + repo.TouchDlls("Release", Outputs); + + Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Release")); + } + + private static TempRepo FreshRepo() + { + var repo = new TempRepo(); + repo.Write("global.json", "{}"); + repo.Write("Directory.Build.props", ""); + repo.Write("Directory.Packages.props", ""); + repo.Write("src/HSchool.AppHost/AppHost.cs", "class A {}"); + repo.Write("src/HSchool.AppHost/HSchool.AppHost.csproj", ""); + repo.Write("src/HSchool.Server/Program.cs", "class S {}"); + repo.Write("src/HSchool.Server/HSchool.Server.csproj", ""); + repo.WriteDll("HSchool.AppHost"); + repo.WriteDll("HSchool.Server"); + repo.Touch("global.json", Inputs); + repo.Touch("Directory.Build.props", Inputs); + repo.Touch("Directory.Packages.props", Inputs); + repo.Touch("src/HSchool.AppHost/AppHost.cs", Inputs); + repo.Touch("src/HSchool.AppHost/HSchool.AppHost.csproj", Inputs); + repo.Touch("src/HSchool.Server/Program.cs", Inputs); + repo.Touch("src/HSchool.Server/HSchool.Server.csproj", Inputs); + repo.TouchDlls("Debug", Outputs); + return repo; + } + + private sealed class TempRepo : IDisposable + { + public string Root { get; } = Path.Combine(Path.GetTempPath(), "h-school-stamp-" + Guid.NewGuid().ToString("N")); + + public TempRepo() => Directory.CreateDirectory(Root); + + public string Write(string relative, string contents) + { + var path = PathUnder(relative); + var dir = Path.GetDirectoryName(path); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(path, contents); + return path; + } + + public string WriteDll(string project, string configuration = "Debug") => + Write($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", "dll"); + + public string Dll(string project, string configuration = "Debug") => + PathUnder($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll"); + + public void Touch(string relative, DateTime utc) => File.SetLastWriteTimeUtc(PathUnder(relative), utc); + + public void TouchDlls(string configuration, DateTime utc) + { + foreach (var project in LaunchBuildStamp.OutputProjects) + { + Touch($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", utc); + } + } + + public void Dispose() + { + try + { + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + } + } + + private string PathUnder(string relative) => + Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tools/apphost-uptodate.ps1 b/tools/apphost-uptodate.ps1 new file mode 100644 index 0000000..20d08e7 --- /dev/null +++ b/tools/apphost-uptodate.ps1 @@ -0,0 +1,80 @@ +# Same rules as tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs. Keep the two in sync. +# Exit 0: AppHost and Server dlls are newer than inputs. Exit 1: need a build. +param( + [Parameter(Mandatory = $true)] + [string] $RepoRoot, + [string] $Configuration = "Debug" +) + +$ErrorActionPreference = "Stop" +$tfm = "net10.0" +$outputs = @( + Join-Path $RepoRoot "src\HSchool.AppHost\bin\$Configuration\$tfm\HSchool.AppHost.dll" + Join-Path $RepoRoot "src\HSchool.Server\bin\$Configuration\$tfm\HSchool.Server.dll" +) + +foreach ($dll in $outputs) { + if (-not (Test-Path -LiteralPath $dll)) { + exit 1 + } +} + +$oldest = $null +foreach ($dll in $outputs) { + $written = (Get-Item -LiteralPath $dll).LastWriteTimeUtc + if ($null -eq $oldest -or $written -lt $oldest) { + $oldest = $written + } +} + +$named = @( + "global.json" + "Directory.Build.props" + "Directory.Packages.props" +) +foreach ($name in $named) { + $path = Join-Path $RepoRoot $name + if ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).LastWriteTimeUtc -gt $oldest) { + exit 1 + } +} + +$src = Join-Path $RepoRoot "src" +if (-not (Test-Path -LiteralPath $src)) { + exit 1 +} + +$extensions = @{ + ".cs" = $true + ".csproj" = $true + ".props" = $true +} +$skip = @{ + "bin" = $true + "obj" = $true + "node_modules" = $true +} + +$stack = New-Object System.Collections.Stack +$stack.Push($src) +while ($stack.Count -gt 0) { + $dir = [string]$stack.Pop() + foreach ($child in [System.IO.Directory]::EnumerateDirectories($dir)) { + $leaf = Split-Path -Path $child -Leaf + if ($skip.ContainsKey($leaf.ToLowerInvariant())) { + continue + } + $stack.Push($child) + } + foreach ($file in [System.IO.Directory]::EnumerateFiles($dir)) { + $ext = [System.IO.Path]::GetExtension($file).ToLowerInvariant() + if (-not $extensions.ContainsKey($ext)) { + continue + } + if ((Get-Item -LiteralPath $file).LastWriteTimeUtc -gt $oldest) { + exit 1 + } + } +} + +exit 0