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 <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 08:45:44 +03:00
co-authored by Cursor
parent 7266678ee9
commit 9bd1b8eea0
9 changed files with 504 additions and 13 deletions
+13 -5
View File
@@ -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 <random>`), 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 <random>`), 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.
+2 -1
View File
@@ -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
+34
View File
@@ -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 и сейв не
трогаем.
+45
View File
@@ -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 поводом пересобрать сервер.
+8
View File
@@ -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 свежие |
+60 -7
View File
@@ -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
@@ -0,0 +1,105 @@
namespace HSchool.AppHost.Tests;
/// <summary>
/// Same rules as <c>tools/apphost-uptodate.ps1</c>. Keep the two in sync.
/// </summary>
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);
}
@@ -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", "<Project />");
repo.Write("Directory.Packages.props", "<Project />");
repo.Write("src/HSchool.AppHost/AppHost.cs", "class A {}");
repo.Write("src/HSchool.AppHost/HSchool.AppHost.csproj", "<Project />");
repo.Write("src/HSchool.Server/Program.cs", "class S {}");
repo.Write("src/HSchool.Server/HSchool.Server.csproj", "<Project />");
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));
}
}
+80
View File
@@ -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