Aquarium/scripts/dev-reload.ps1
2026-05-17 23:37:20 +01:00

359 lines
11 KiB
PowerShell

param(
[switch]$Headless,
[switch]$NoStop,
[switch]$BuildOnly,
[switch]$Reopen,
[string]$ClientProject = "src\Aquarium.Epiphany\Aquarium.Epiphany.csproj",
[int]$RetainSlots = 12,
[int]$StartupTimeoutSeconds = 5,
[int]$HeadlessTimeoutSeconds = 60
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$projectPath = Join-Path $repoRoot "src\Aquarium.Engine\Aquarium.Engine.csproj"
$liveProjectPath = if ([System.IO.Path]::IsPathRooted($ClientProject)) { $ClientProject } else { Join-Path $repoRoot $ClientProject }
$liveProjectPath = (Resolve-Path $liveProjectPath).Path
$liveAssemblyName = [System.IO.Path]::GetFileNameWithoutExtension($liveProjectPath)
$devRoot = Join-Path $repoRoot "artifacts\dev-reload"
$slotRoot = Join-Path $devRoot "slots"
$visibleStatePath = Join-Path $devRoot "state.clixml"
$headlessStatePath = Join-Path $devRoot "headless-state.clixml"
$statePath = if ($Headless) { $headlessStatePath } else { $visibleStatePath }
$buildStatePath = Join-Path $devRoot "last-build.clixml"
$liveReloadPointerPath = Join-Path $devRoot "live-current.txt"
$cultCacheDirectory = if ($Headless) { "headless-cultcache" } else { "cultcache" }
$cultCachePath = Join-Path $devRoot "$cultCacheDirectory\aquarium-client.msgpack"
$logName = if ($Headless) { "headless" } else { "latest" }
$stdoutLogPath = Join-Path $devRoot "$logName.out.log"
$stderrLogPath = Join-Path $devRoot "$logName.err.log"
New-Item -ItemType Directory -Force -Path $slotRoot | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $cultCachePath) | Out-Null
if ($Reopen -and $Headless) {
throw "-Reopen is for the visible dev window; do not combine it with -Headless."
}
function Stop-PreviousOwnedProcess {
if ($NoStop -or -not (Test-Path $statePath)) {
return
}
$state = Import-Clixml -Path $statePath
if (-not ($state.PSObject.Properties.Name -contains "pid")) {
return
}
if (-not $state.pid) {
return
}
$process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if (-not $process) {
return
}
$recordedPath = [string]$state.slot
$commandLine = $null
try {
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
}
catch {
$commandLine = $null
}
if (-not $commandLine -or $commandLine -notlike "*$recordedPath*") {
Write-Host "Previous PID $($state.pid) no longer belongs to the recorded slot; leaving it alone."
return
}
Write-Host "Stopping previous script-owned Aquarium process $($state.pid)."
Stop-Process -Id ([int]$state.pid) -Force
}
function Remove-OldSlots {
if ($RetainSlots -lt 1) {
return
}
$slots = Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots
foreach ($slot in $slots) {
try {
Remove-Item -LiteralPath $slot.FullName -Recurse -Force
}
catch {
Write-Host "Could not remove old slot $($slot.FullName): $($_.Exception.Message)"
}
}
}
function Get-StderrTail {
if (-not (Test-Path $stderrLogPath)) {
return "<stderr log does not exist>"
}
$tail = Get-Content -Path $stderrLogPath -Tail 80 -ErrorAction SilentlyContinue
if (-not $tail) {
return "<stderr log is empty>"
}
return ($tail -join [Environment]::NewLine)
}
function Wait-ForStartedAquarium {
param(
[System.Diagnostics.Process]$Process,
[bool]$ExpectWindow
)
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $StartupTimeoutSeconds))
while ((Get-Date) -lt $deadline) {
$Process.Refresh()
if ($Process.HasExited) {
throw "Aquarium process exited during startup with code $($Process.ExitCode).`n$(Get-StderrTail)"
}
if (-not $ExpectWindow) {
return
}
if ($Process.MainWindowHandle -ne [IntPtr]::Zero) {
return
}
Start-Sleep -Milliseconds 100
}
if ($ExpectWindow) {
try {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken reload.
}
throw "Aquarium process started but did not open a visible window within $StartupTimeoutSeconds seconds.`n$(Get-StderrTail)"
}
}
function Wait-ForHeadlessAquarium {
param(
[System.Diagnostics.Process]$Process
)
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $HeadlessTimeoutSeconds))
while ((Get-Date) -lt $deadline) {
$Process.Refresh()
if ($Process.HasExited) {
$Process.WaitForExit()
$exitCode = [int]$Process.ExitCode
if ($exitCode -ne 0) {
throw "Headless Aquarium exited with code $exitCode.`n$(Get-StderrTail)"
}
return
}
Start-Sleep -Milliseconds 100
}
try {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
}
catch {
# Best effort cleanup before surfacing the broken smoke test.
}
throw "Headless Aquarium did not exit within $HeadlessTimeoutSeconds seconds; killed PID $($Process.Id).`n$(Get-StderrTail)"
}
function Start-AquariumProcess {
param(
[string]$SlotPath,
[string]$LiveAssemblyPath,
[bool]$RunHeadless
)
$exePath = Join-Path $SlotPath "Aquarium.Engine.exe"
if (-not (Test-Path $exePath)) {
throw "Expected apphost does not exist: $exePath"
}
if (-not (Test-Path $LiveAssemblyPath)) {
throw "Expected client runtime assembly does not exist: $LiveAssemblyPath"
}
Set-Content -Path $liveReloadPointerPath -Value $LiveAssemblyPath -Encoding UTF8
$arguments = @(
"--cache", $cultCachePath,
"--shader-source", (Join-Path $SlotPath "Render\Shaders\D3D12HeightField.hlsl"),
"--client-assembly", $LiveAssemblyPath,
"--client-reload-pointer", $liveReloadPointerPath
)
if ($RunHeadless) {
$arguments += "--headless"
}
$startProcessParameters = @{
FilePath = $exePath
ArgumentList = $arguments
WorkingDirectory = $SlotPath
RedirectStandardOutput = $stdoutLogPath
RedirectStandardError = $stderrLogPath
PassThru = $true
}
if ($RunHeadless) {
$startProcessParameters.WindowStyle = "Hidden"
}
return Start-Process @startProcessParameters
}
function Reopen-VisibleProcess {
if (-not (Test-Path $visibleStatePath)) {
throw "No visible dev-reload state file exists. Run scripts\dev-reload.ps1 once to create a launch slot."
}
$state = Import-Clixml -Path $visibleStatePath
if (-not $state.slot) {
throw "Visible dev-reload state does not record a slot."
}
if ($state.PSObject.Properties.Name -contains "pid" -and $state.pid) {
$existing = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Visible Aquarium process $($state.pid) is already running."
return
}
}
$slotPath = [string]$state.slot
$liveAssemblyPath = Join-Path $slotPath "$liveAssemblyName.dll"
if ($state.PSObject.Properties.Name -contains "liveAssembly" -and $state.liveAssembly) {
$liveAssemblyPath = [string]$state.liveAssembly
}
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless:$false
Wait-ForStartedAquarium -Process $process -ExpectWindow:$true
@{
slot = $slotPath
builtAt = $state.builtAt
reopenedAt = (Get-Date).ToString("o")
pid = $process.Id
liveAssembly = $liveAssemblyPath
headless = $false
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $visibleStatePath
Write-Host "Aquarium reopened from last visible slot."
Write-Host " PID: $($process.Id)"
Write-Host " Slot: $slotPath"
return
}
if ($Reopen) {
Reopen-VisibleProcess
exit 0
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$slotName = "$timestamp-$([guid]::NewGuid().ToString("N").Substring(0, 8))"
$slotPath = Join-Path $slotRoot $slotName
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
Write-Host "Building Aquarium into disposable slot:"
Write-Host " $slotPath"
dotnet build $projectPath `
-c Debug `
-o $slotPath `
/p:UseAppHost=true
if ($LASTEXITCODE -ne 0) {
throw "dotnet build failed with exit code $LASTEXITCODE."
}
dotnet build $liveProjectPath `
-c Debug `
-o $slotPath
if ($LASTEXITCODE -ne 0) {
throw "client runtime build failed with exit code $LASTEXITCODE."
}
if ($BuildOnly) {
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $buildStatePath
Write-Host "Build-only slot ready."
Remove-OldSlots
exit 0
}
Stop-PreviousOwnedProcess
$exePath = Join-Path $slotPath "Aquarium.Engine.exe"
$liveAssemblyPath = Join-Path $slotPath "$liveAssemblyName.dll"
if (-not (Test-Path $exePath)) {
throw "Expected apphost was not produced: $exePath"
}
if (-not (Test-Path $liveAssemblyPath)) {
throw "Expected client runtime assembly was not produced: $liveAssemblyPath"
}
$process = Start-AquariumProcess -SlotPath $slotPath -LiveAssemblyPath $liveAssemblyPath -RunHeadless:$Headless
if ($Headless) {
Wait-ForHeadlessAquarium -Process $process
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
liveAssembly = $liveAssemblyPath
headless = $true
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $statePath
Write-Host "Headless Aquarium completed from disposable slot."
Write-Host " Slot: $slotPath"
Write-Host " CultCache: $cultCachePath"
Write-Host " Stdout: $stdoutLogPath"
Write-Host " Stderr: $stderrLogPath"
Remove-OldSlots
exit 0
}
Wait-ForStartedAquarium -Process $process -ExpectWindow:$true
@{
slot = $slotPath
builtAt = (Get-Date).ToString("o")
pid = $process.Id
liveAssembly = $liveAssemblyPath
headless = [bool]$Headless
stdoutLog = $stdoutLogPath
stderrLog = $stderrLogPath
} | Export-Clixml -Path $statePath
Write-Host "Aquarium running from disposable slot."
Write-Host " PID: $($process.Id)"
Write-Host " CultCache: $cultCachePath"
Write-Host " Stdout: $stdoutLogPath"
Write-Host " Stderr: $stderrLogPath"
Write-Host "Run this script again to replace only this script-owned process."
if (-not $Headless) {
Write-Host "Run with -Reopen to reopen this visible slot without rebuilding."
}
Remove-OldSlots