From f881e5b46724a571e6d25da5085bc7f4ca251256 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:09:08 +0200 Subject: [PATCH] feat(launcher): prepare Campaign LA11 user gate --- AcDream.slnx | 2 + docs/ISSUES.md | 66 +- docs/architecture/acdream-architecture.md | 5 +- docs/plans/2026-08-14-launcher-campaign.md | 7 +- .../2026-08-14-campaign-la-test-script.md | 571 ++++++++++++ .../2026-08-14-launcher-campaign-design.md | 4 +- .../Launching/ILauncherChildProcess.cs | 23 +- .../Launching/LauncherProcessSpec.cs | 8 +- .../Launching/LauncherProcessSupervisor.cs | 4 +- .../Launching/WindowsSystemChildProcess.cs | 843 ++++++++++++++++++ .../Orchestration/LauncherExecutableSet.cs | 3 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 + .../LauncherStartupOptions.cs | 248 ++++++ ...e.Tests.Fixtures.ConsoleSignalChild.csproj | 10 + .../Program.cs | 54 ++ ...ixtures.ConsolelessSupervisorParent.csproj | 14 + .../Program.cs | 135 +++ .../AcDream.Launcher.Core.Tests.csproj | 12 + .../LauncherProcessSupervisorTests.cs | 318 +++++++ .../LauncherExecutableSetTests.cs | 10 + .../LauncherStartupOptionsTests.cs | 182 ++++ tools/new-campaign-la-update-fixture.ps1 | 333 +++++++ tools/run-campaign-la-preflight.ps1 | 394 ++++++++ tools/test-campaign-la-session-status.ps1 | 346 +++++++ 24 files changed, 3538 insertions(+), 58 deletions(-) create mode 100644 docs/research/2026-08-14-campaign-la-test-script.md create mode 100644 src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs create mode 100644 src/AcDream.Launcher/LauncherStartupOptions.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs create mode 100644 tools/new-campaign-la-update-fixture.ps1 create mode 100644 tools/run-campaign-la-preflight.ps1 create mode 100644 tools/test-campaign-la-session-status.ps1 diff --git a/AcDream.slnx b/AcDream.slnx index 6080bb7f..b003cfca 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -28,6 +28,8 @@ + + diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 881ed834..421731ab 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,53 +26,41 @@ What does NOT go here: ## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host -**Status:** OPEN +**Status:** IN-PROGRESS — the isolated process-group implementation and real +Windows fixtures are complete; the LA11 connected acceptance row remains +required before closure. **Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE account session stuck for several minutes — a documented project landmine; see CLAUDE.md "Logout-before-reconnect") **Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3) **Component:** Launcher.Core / process supervision -**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful -stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE -`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke -(`kill(pid, 2)`), which the K4-proven headless host already turns into an -ACE-confirmed graceful logout. On Windows there is no equivalent today for a -console process with no message-pump window: `CloseMainWindow` is a no-op -for a console host (there is no `HWND` to target), and -`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process -today — Windows delivers console control events to every process attached -to the SAME console as the calling process, so an unscoped call would also -signal the launcher itself (and anything else sharing that console), not -just the intended child. `TryRequestGracefulStop` therefore returns `false` -on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow` -(still a no-op for a console child) and then the timeout-driven `Kill()` — -exactly the hard-kill behavior this finding was written to describe, just -with a documented (rather than silent) gap. +**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts +`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and +the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`. +On Windows, console-capable launcher specs now use a narrow no-shell +`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and +an explicit inherited-handle list that preserves only redirected stdin plus +stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a +console for the creation transaction, detaches after the new group inherits +it, and later attaches only long enough to send +`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such +child is therefore both the root of its own process group and, for the normal +Explorer-launched case, attached to its own console. Graphical children opt +out and retain the ordinary `Process`/`WM_CLOSE` path. -**Known fix direction (not yet implemented).** Spawn the Windows child with -the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native -`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process` -plumbing in `SystemChildProcess`) so the child gets its own console process -group, detached from the launcher's own group. Then -`TryRequestGracefulStop` on Windows calls -`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` — -`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and, -unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a -process that has installed no console-control handler at all (the default -CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't -strictly need new code to receive SOME form of shutdown from it) — though -wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into -the same graceful-shutdown path K4 already built for Linux SIGINT is the -better long-term target, so a Windows headless launch gets the identical -ACE-confirmed graceful logout instead of just "exits somehow." +Two real Windows fixture gates cover both a console parent and a consoleless +WinExe parent. They prove exact complex argv, redirected stdin, receipt of a +targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`, +and a sibling process group that remains running until it receives its own +targeted break. Safe-handle cleanup, early-failure termination, and the +Linux SIGINT gate remain covered by the Launcher.Core suite. -**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows -children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends -`CTRL_BREAK_EVENT` to that child's process group on Windows; a live -connected gate proves `AcDream.Headless` exits gracefully (ACE clears the -session immediately, not after the ~3-minute stale-session window) when -stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the +**Acceptance for closing this issue:** automated process-group and targeted- +signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live +connected row proves `AcDream.Headless` exits gracefully and ACE clears the +session immediately (not after the ~3-minute stale-session window) when +stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the Linux SIGINT behavior. ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1d1f44de..c909820d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -312,7 +312,10 @@ src/ AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner Profiles/ -> sole credential/profile document + CRUD owner - Launching/ -> config composition and supervised process seams + Launching/ -> config composition and supervised process seams; + Windows console hosts are no-shell, redirected- + stdin process-group leaders receiving targeted + CTRL_BREAK, while Linux hosts receive SIGINT Status/ -> incremental host-status parsing/tailing Orchestration/ -> immutable UI snapshots, typed actions, capability gates, and running-session lifetime diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 180d320f..76fd65bc 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions. ## LA11 — closeout -- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` +- One exact operator script + `docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the + connection-free `tools/run-campaign-la-preflight.ps1` and followed by + serial user rows, covering: all three launch modes vs local ACE, probe round-trip ×2 (no lingering session), char-select visual matrix + delete flow, login-commands + plugin behavior on both hosts, add-server/add-account purely in UI, @@ -729,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | — | | | | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md new file mode 100644 index 00000000..664a569e --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -0,0 +1,571 @@ +# Campaign LA11 — automated preflight and connected user gate + +**Status:** implementation checkpoint only. Run this script after the reviewed +LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA, +the Linux graphical client, and issue #397 remain open until the user records a +verdict for every applicable row below. + +This is the single Campaign LA operator script. The automated section is +display-free and connection-free. Rows A–I are deliberately manual and serial: +they use real retail DATs, a local ACE server, user-entered credentials, and +visual judgment that automation cannot supply. + +## 1. Safety boundary and required inputs + +Use placeholders throughout; never paste a password into a terminal, this +document, a screenshot, or a gate report. + +- ``: a clean Campaign LA worktree at the exact commit + under test. +- ``: a read-only source containing + `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and + `client_local_English.dat`. +- ``, ``, and ``: a local ACE endpoint and + account. Enter the account password only in the launcher's masked Password + field. The launcher intentionally stores it as plaintext in the **isolated** + `launcher-profiles.json`; children receive it through redirected stdin. +- ``: a second user-controlled character that can observe a + private `/tell` from each play mode. +- ``: a server-operator-approved disposable character. + Never substitute a primary character. If none exists, provision one with the + local server's normal admin procedure before row G. +- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported + Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux + desktop/WSLg is required for row I. The Avalonia launcher is supported on + Linux; `gui` and `guiSelect` **client** actions must remain disabled with the + Modern Runtime Slice-L explanation. + +Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher +before starting. Do not run another acdream gate in parallel. All generated +files must stay below one new gate directory; the canonical `%APPDATA%`, +`%LOCALAPPDATA%`, and XDG acdream roots are out of scope. + +## 2. Automated preflight — no UI, connection, credential, or bake + +In PowerShell 7 on Windows: + +```powershell +$Repo = [IO.Path]::GetFullPath('') +$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp" +$Preflight = Join-Path $Gate 'automated-preflight' +New-Item -ItemType Directory -Path $Gate | Out-Null + +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory $Preflight + +$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | + ConvertFrom-Json +if (-not $Report.success -or $Report.dirty) { + throw 'Stop: automated preflight failed or recorded a dirty worktree.' +} +if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) { + throw 'Stop: preflight HEAD does not equal the current HEAD.' +} +``` + +The expected matrix is: + +| Platform | Automated command group | Required result | Typical time | +|---|---|---|---:| +| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | +| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | +| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | +| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | +| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | +| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | + +`report.json` records the tested HEAD/dirty state, OS/RID, exact commands, +durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight +plans 26 commands. It never launches App or Headless in connected mode and +never reads a credential. + +### Optional installed-DAT read-only row + +This is not a bake and must not replace row A. Add the switches below only when +the DAT directory may be read by tests: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` + -IncludeInstalledDat ` + -InstalledDatDirectory '' +``` + +The mandatory installed-DAT result is +`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and +`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX +and fails if the test skipped or did anything other than pass. The action-map +and portal-asset probes are additional coverage, never substitutes. Expected +matrix size: 30 rows. + +On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository +path, and a Linux output path. Do not treat a Windows-hosted run over +`wsl.exe` as the Linux row. + +## 3. Prepare the deterministic local A/B feed + +Build distinct, version-stamped payloads so the staged launcher really changes +from A to B. These commands write only below `$Gate` (normal project `obj/bin` +incremental outputs are the already-authorized build outputs): + +```powershell +$VersionA = '1.0.1-la11.a' +$VersionB = '1.0.1-la11.b' +$Payloads = Join-Path $Gate 'update-payloads' +$Fixture = Join-Path $Gate 'update-fixture' + +function Publish-LaRelease([string]$Version, [string]$Label) { + $ClientWin = Join-Path $Payloads "$Label/client-win-x64" + $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64" + $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64" + $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64" + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherWin --nologo + if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" } + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherLinux --nologo + if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" } +} + +Publish-LaRelease $VersionA 'A' +Publish-LaRelease $VersionB 'B' + +pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') ` + -OutputDirectory $Fixture ` + -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') ` + -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') ` + -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') ` + -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') ` + -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') ` + -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') ` + -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') ` + -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64') +``` + +The helper rejects nonempty output, invalid or non-monotonic versions, missing +root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only +server, and an A/B selector. It does not download or mutate payload sources. + +Start the Windows loopback server without a shell or visible helper window: + +```powershell +$ServerInfo = [Diagnostics.ProcessStartInfo]::new() +$ServerInfo.FileName = (Get-Command pwsh).Source +$ServerInfo.UseShellExecute = $false +$ServerInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'), + '-Root', $Fixture, '-Port', '43119')) { + $ServerInfo.ArgumentList.Add($Value) +} +$FixtureServer = [Diagnostics.Process]::Start($ServerInfo) +$ManifestUri = 'http://127.0.0.1:43119/manifest.json' +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) { + throw 'Stop: local fixture did not begin on release A.' +} +``` + +## 4. Windows isolated launcher command and evidence rule + +```powershell +$WinRoot = Join-Path $Gate 'windows-roots' +$WinConfig = Join-Path $WinRoot 'config' +$WinData = Join-Path $WinRoot 'data' +$WinCache = Join-Path $WinRoot 'cache' +$Evidence = Join-Path $Gate 'evidence' +New-Item -ItemType Directory -Path $Evidence | Out-Null + +$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe' +$LauncherArguments = @( + '--config-dir', $WinConfig, + '--data-dir', $WinData, + '--cache-dir', $WinCache, + '--update-manifest-uri', $ManifestUri) +& $LauncherA @LauncherArguments +``` + +All four options are process-local. The three roots are an indivisible set; +the local feed reaches only the updater and is never persisted. A self-update +must preserve the same validated suffix through helper and confirmation +restarts. The launcher, profiles, installer, current-version store, updater, +session composer, and orchestrator must all use this one exact path set. + +For every play/probe row, copy the session id shown in the launcher's Sessions +list into ``, then run: + +```powershell +$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl' +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile $Status ` + -Mode '' ` + -ExpectedSessionId '' ` + -ReportPath (Join-Path $Evidence '-status.validation.json') +``` + +Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact +v1 fields **and property order**, one session id, UTC monotonic timestamps, +mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login +command failure, credential redaction, and no surviving App/Headless process. +Its report contains event names and a hash, not account, character, command, or +error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them. + +## 5. Serial Windows user rows A–H + +### A — isolated first run, real DAT bake, and release-A client baseline + +1. Confirm the launcher opens First-run setup and all launch buttons are + unavailable. Save a redacted screenshot as `A-first-run-required.png`. +2. Enter `` in the wizard, select a sensible + worker count, and click **Validate**. Confirm all four DATs pass. +3. Click **Build and install**. Do not cancel or close the launcher. The real + bake may take 30–180 minutes. Confirm every phase reaches **Completed** and + the status says `Client content installed and verified. Launch is enabled.` +4. Open **Check for updates**. Confirm available release A, click **Install + client**, and wait for `Client update installed and activated.` Do not stage + launcher A; the test launcher already has version A. +5. Confirm these exact isolated artifacts exist and no `.previous-install` + remains after success: + +```powershell +$RequiredA = @( + (Join-Path $WinData 'install.json'), + (Join-Path $WinData 'pak/acdream.pak'), + (Join-Path $WinData 'app/current.json')) +foreach ($Path in $RequiredA) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" } +} +$RequiredA | ForEach-Object { + $Item = Get-Item -LiteralPath $_ + [ordered]@{ + name = $Item.Name + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant() + } +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json') +``` + +Do not copy `install.json` into shared evidence because it records the local DAT +path. Expected time: 45–200 minutes including bake. + +### B — server/account CRUD entirely through the UI + +1. Add `` at `127.0.0.1:`, edit its name + and port, then remove it. Confirm Cancel/Escape makes no mutation. +2. Add `` at `127.0.0.1:`. +3. Under it add `` with a user-invented throwaway field + value, edit its account name/value, then remove it. Do not reuse a real + password for this temporary row. +4. Add `` and enter its real password only in the masked field. +5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm + only the real server/account persisted. Save redacted before/reopen images as + `B-crud-before-reopen.png` and `B-crud-after-reopen.png`. +6. Record only the profile file's size/hash, never its contents: + +```powershell +$Profile = Join-Path $WinConfig 'launcher-profiles.json' +$Item = Get-Item -LiteralPath $Profile +[ordered]@{ + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json') +``` + +Expected time: 10–15 minutes. + +### C — live character probe twice, no stale ACE session + +1. Select ``, click **Refresh characters**, and wait for the + probe row to finish. Confirm the roster appears without entering world. +2. Run the validator in `probe` mode for its session id. Confirm its exact + event order is `started, connected, characterList, disconnected, exited`, + with no `enteredWorld`, terminal code 0, and terminal reason `probe`. +3. In the ACE console/session administration view, confirm the account is no + longer logged in. Save a redacted `C-probe-1-ace-cleared.png`. +4. Repeat steps 1–3 immediately, producing a different session id, + `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`. +5. Confirm Refresh is re-enabled and no `acdream-headless` process remains. + +Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure; +do not wait three minutes and call the next attempt a pass. + +### D — `guiSelect`, retail character screen, plugin, and login command + +1. Select one non-disposable roster character. Set its mode to `guiSelect`, + Plugins to exactly `acdream.smoke`, and its one login command to + `/tell , LA11-D-`. Save settings. +2. Click **GUI — character select**. Confirm the flat retail character list, + selection highlight, Enter button, Delete/Restore swap state, dialogs, and + absence of any invented rotating 3D preview. Save redacted + `D-character-select.png`. +3. Select the configured character and enter world. Confirm the observer gets + the exact D nonce once. Save `D-observer-tell.png` with names redacted. +4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE + releases the account. Validate `guiSelect` with + `-ExpectedPlugin acdream.smoke`. + +Expected time: 5–10 minutes. + +### E — direct `gui`, plugin, and login command + +1. Change the same character to `gui`, retain `acdream.smoke`, and change the + command nonce to `LA11-E-`. +2. Click **GUI — enter world**. Confirm it selects the exact cached character, + reaches the world, loads the plugin once, and the observer gets the E nonce + once. +3. Stop from the launcher, confirm ACE logout, and validate `gui` with the + expected plugin. Save `E-world.png`, `E-observer-tell.png`, and + `E-status.validation.json` with identifying text redacted. + +Expected time: 5–10 minutes. + +### F — headless, plugin/login command, and connected #397 acceptance + +1. Change the same character to `headless`, retain `acdream.smoke`, and use + `LA11-F-`. +2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`, + and the observer's single exact F nonce. +3. Click **Stop** once. On Windows this must target that child's distinct + process group with `CTRL_BREAK`; it must reach `disconnected` then + `exited(code:0, reason:graceful)` before the timeout, without a hard kill. + ACE must release the account immediately and the launcher must stay open. +4. Validate `headless` with the expected plugin and save + `F-status.validation.json` plus redacted ACE-clear evidence. + +The real automated fixture separately proves complex argv and redirected stdin +survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling +process group receives nothing, exit 0 precedes timeout, and `Kill` is never +called. This connected row proves the actual ACE graceful-logout half. Issue +#397 remains open if either half is missing. Expected time: 5–10 minutes. + +### G — disposable delete and restore + +1. Launch `guiSelect` for ``. Do not enter world. +2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click + Delete, inspect the retail confirmation dialog, cancel once, and confirm no + state change. +3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown, + disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`. +4. Click Restore and confirm the same GUID returns to ordinary state with + Enter/Delete enabled and Restore disabled. Save `G-restored.png`. +5. Close through launcher **Stop**, confirm graceful terminal status and ACE + release. Validate with: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` + -Mode guiSelect ` + -ExpectNoEnteredWorld ` + -ExpectedSessionId '' ` + -ExpectedPlugin acdream.smoke ` + -ReportPath (Join-Path $Evidence 'G-status.validation.json') +``` + +If restore fails, stop the row, preserve evidence, and restore only that +disposable character with the server's normal admin recovery. Never continue +with another character. Expected time: 5–10 minutes. + +### H — local A→B client update, active-session refusal, rollback, self-update + +1. Record release A's `app/current.json`. Start one headless session and wait + for `enteredWorld`. +2. Switch the fixture atomically to B: + +```powershell +pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') ` + -Release B -Root $Fixture +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) { + throw 'Stop: fixture did not switch to B.' +} +``` + +3. Open **Check for updates** and **Check again**. While the session is active, + confirm Install client, Rollback client, and Stage launcher are disabled or + refuse without changing `app/current.json`. Save `H-active-refusal.png`. +4. Stop the headless session and validate its graceful status. Install client + B. Confirm `app/current.json` names B, A is previous, all installed-file + hashes verify, and new sessions resolve from the B directory. +5. Click **Rollback client**. Confirm A becomes current and B becomes previous. + Check again and install B once more, leaving B current. Save sanitized copies + of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and + `H-pointer-rollback-a.json`; they contain no credentials. +6. Click **Stage launcher**. Confirm restart is required, then close the + launcher normally. The copied helper must apply B and restart the launcher + with the same config/data/cache/feed suffix. +7. Confirm profiles, install record, and update state still come from the + isolated roots; `campaign-la-fixture-release.txt` beside the relaunched + executable says `release=B`; `launcher-update/pending.json` is gone; and no + transaction backup remains. Check again and confirm launcher B is current. + Save `H-self-update-confirmed.png` and a hash-only post-state inventory. + +Never edit a manifest to force this row and never point the launcher at a +non-loopback HTTP endpoint. Expected time: 15–30 minutes. + +## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots + +Stop the Windows launcher and fixture server only after every Windows session +is terminal: + +```powershell +if (-not $FixtureServer.HasExited) { + $FixtureServer.Kill() + $FixtureServer.WaitForExit() +} +``` + +In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository +and fixture may be read from a mounted Windows path, but roots must live on the +Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs +cannot escape the Linux environment: + +```powershell +$RepoLinux = [IO.Path]::GetFullPath('') +$FixtureLinux = [IO.Path]::GetFullPath('') +$PayloadsLinux = [IO.Path]::GetFullPath('') +$LinuxGate = [IO.Path]::GetFullPath('') +$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home' +$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home' +$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home' +$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream' +$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream' +$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream' +$LinuxEvidence = Join-Path $LinuxGate 'evidence' +New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null + +pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') ` + -Release A -Root $FixtureLinux +``` + +Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native +terminal and leave it running. In another terminal: + +```powershell +$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher' +& $LauncherLinuxA ` + --config-dir $LinuxConfig ` + --data-dir $LinuxData ` + --cache-dir $LinuxCache ` + --update-manifest-uri 'http://127.0.0.1:43119/manifest.json' +``` + +Complete this exact serial matrix: + +1. **Manual-DAT first run:** enter ``; + auto-detection may be empty by design. Validate, bake to + `$LinuxData/pak/acdream.pak`, verify, then install release-A client. +2. **CRUD:** add/edit/remove a temporary server and account entirely in the + launcher, then add the real Linux-reachable ACE profile. Enter its password + only in the masked field. Restart and confirm persistence. Run + `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must + be `600`. +3. **Probe twice:** run Refresh twice, validate both status streams in `probe` + mode with native `pwsh`, and confirm ACE clears the account after each. +4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled + and show the explicit Modern Runtime Slice-L message. Do not bypass this + disablement and do not claim a Linux graphical-client gate. +5. **Headless:** configure `acdream.smoke` and + `/tell , LA11-I-`, launch, observe + the tell, click Stop, and validate `headless` + expected plugin. Native Linux + sends SIGINT and must reach graceful terminal status with no process leak. +6. **Update:** switch the native fixture to B, prove update actions refuse while + a headless session is active, stop it gracefully, install B, rollback to A, + reinstall B, stage launcher B, and close normally. Confirm the relaunched + binary's B marker, preserved explicit roots/feed, cleaned pending journal, + and executable owner bits on App, Headless, Launcher, and Bake. + +Copy only redacted screenshots, validation reports, pointer JSON, hashes, and +file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session +files local. Expected time: 60–220 minutes, dominated by the real bake. + +## 7. Evidence, redaction, verdict, and cleanup + +Expected evidence tree: + +```text +logs/campaign-la-user-gate-/ + automated-preflight/report.json + automated-preflight/commands/*.log + automated-preflight/publish/{win-x64,linux-x64}/... + update-fixture/fixture-report.json + update-fixture/{A,B}/manifest.json + evidence/A-install-hashes.json + evidence/B-*.png + evidence/C-probe-{1,2}-status.validation.json + evidence/D-*.png + D-status.validation.json + evidence/E-*.png + E-status.validation.json + evidence/F-*.png + F-status.validation.json + evidence/G-*.png + G-status.validation.json + evidence/H-*.png + H-pointer-*.json + evidence/I-*.png + I-status.validation.json + I-modes.txt + verdict.json +``` + +Before sharing evidence: + +- remove or mask account names, character names, DAT paths, hostnames other than + loopback, and server-admin identifiers from screenshots; +- never copy `launcher-profiles.json`, raw session configs/status streams, + stdout/stderr that may contain user text, or environment values; +- search the shareable evidence for the exact user-entered password and any + gate-only sentinel secret; the match count must be zero; +- retain SHA-256 and sizes so local raw artifacts remain auditable. + +No additional raw child/plugin diagnostic sink is required: `pluginLoaded`, +the strict terminal status, the observer's redacted tell evidence, and the +automated targeted-signal fixture cover the acceptance questions without +capturing credentials or arbitrary chat. + +Create `verdict.json` manually with schema version 1, exact tested HEAD, rows +A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and +the user's overall verdict. Row I is not applicable only when no native +Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the +current Linux requirement, so it cannot be silently omitted. + +Cleanup is graceful-first and serial: + +1. Restore `` and verify it is ordinary before closing + its session. +2. Stop every launcher session once; require a passing validator and ACE-clear + observation. If a child survives the timeout, record the gate failure and + its PID before any emergency termination. +3. Close each launcher normally, then stop only the fixture-server process + created above. Do not kill ACE as a substitute for logout evidence. +4. Leave update pointers on B or roll the **isolated** client back to A through + the UI; never edit pointers or journals by hand. +5. Remove the real account through the isolated launcher UI. After review, + delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots + that held plaintext passwords, or change the test account password. Do not + recursively delete a computed, empty, canonical, home, repository, or XDG + parent path. +6. Preserve the redacted evidence and reports. The large isolated pak/payload + trees may be removed only after resolving and checking their full paths are + descendants of the recorded gate roots. + +Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial +test suites. A failure stops the current row; restore/stop/collect evidence, +then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped +until the user accepts the complete applicable matrix. diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index db477433..dbd662b3 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -331,7 +331,9 @@ the process supervisor executes. Campaign V); visuals settle at the user gate. - **Headless plugin host:** fixture plugin in the Headless suite (load, capability flag, teardown). -- **Connected gates (user-driven):** every launch mode against local ACE +- **Connected gates (user-driven):** execute the exact serial matrix in + `docs/research/2026-08-14-campaign-la-test-script.md` only after its + connection-free automated preflight passes. Cover every launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs index 872c0130..b8816ab7 100644 --- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable /// documented project landmine; see CLAUDE.md /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved /// the headless host's SIGINT handler produces an ACE-confirmed - /// graceful logout). On Windows there is no reliable cross-console - /// mechanism for an arbitrary no-window child process today — see - /// docs/ISSUES.md for the tracked gap and fix direction; this - /// returns false there. Returns true only when the signal was - /// actually delivered; never throws. + /// graceful logout). On Windows, console-capable children are started + /// as distinct process-group leaders and receive a targeted + /// CTRL_BREAK_EVENT. Returns true only when the signal was actually + /// delivered; never throws. /// bool TryRequestGracefulStop(); @@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory { public ILauncherChildProcess Create(LauncherProcessSpec spec) => - new SystemChildProcess(spec); + OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop + ? new WindowsSystemChildProcess(spec) + : new SystemChildProcess(spec); } internal sealed partial class SystemChildProcess : ILauncherChildProcess @@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess private static partial int kill(int pid, int sig); private readonly Process _process; + private readonly bool _supportsConsoleGracefulStop; private bool _raisingEnabled; internal SystemChildProcess(LauncherProcessSpec spec) { ArgumentNullException.ThrowIfNull(spec); + _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop; var startInfo = new ProcessStartInfo { @@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess public bool TryRequestGracefulStop() { - if (!OperatingSystem.IsLinux()) + if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop) { - // No reliable cross-console mechanism exists for an - // arbitrary no-window Windows child process — tracked gap, - // see docs/ISSUES.md. + // Windows console-capable children use + // WindowsSystemChildProcess. Graphical/non-console children + // deliberately retain the Process/WM_CLOSE path. return false; } diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs index 11b59dee..599a932d 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching; /// Deliberately carries no credential field — the password is a separate /// transient parameter to /// that flows only to the child's stdin, never into this spec, an -/// argument list, or a process environment. +/// argument list, or a process environment. Console-capable specs set +/// so Windows starts them +/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and +/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE. /// public sealed record LauncherProcessSpec( string ExecutablePath, IReadOnlyList Arguments, - string? WorkingDirectory = null); + string? WorkingDirectory = null, + bool SupportsConsoleGracefulStop = true); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index 3a4c2923..252847c5 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor /// /// Requests a graceful stop — first /// (SIGINT - /// on Linux; a no-op on Windows today, see - /// 's docs), + /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console + /// children), /// then — falling /// back to if the process has /// not exited within . A no-op if diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs new file mode 100644 index 00000000..00138c30 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs @@ -0,0 +1,843 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Windows launcher child created without a shell as a true console process- +/// group leader. The native start is deliberately narrow: it exists only +/// because does not expose +/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin. +/// +internal sealed class WindowsSystemChildProcess : ILauncherChildProcess +{ + private readonly LauncherProcessSpec _spec; + private readonly IWindowsConsoleControl _consoleControl; + private Process? _process; + private TextWriter? _standardInput; + private int _processGroupId; + private bool _raisingEnabled; + + internal WindowsSystemChildProcess( + LauncherProcessSpec spec, + IWindowsConsoleControl? consoleControl = null) + { + _spec = spec ?? throw new ArgumentNullException(nameof(spec)); + _consoleControl = consoleControl ?? WindowsConsoleControl.Instance; + } + + public bool HasExited => RequireProcess().HasExited; + + public int ExitCode => RequireProcess().ExitCode; + + public TextWriter StandardInput => _standardInput + ?? throw new InvalidOperationException("The child process has not started."); + + public event EventHandler? Exited; + + public void Start() + { + if (_process is not null) + { + throw new InvalidOperationException("The child process already started."); + } + + WindowsProcessStartResult started = WindowsProcessNative.Start(_spec); + try + { + _process = Process.GetProcessById(started.ProcessId); + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _standardInput = started.TakeStandardInput(); + _processGroupId = started.ProcessId; + started.Resume(); + } + catch + { + started.Terminate(); + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + + throw; + } + finally + { + started.Dispose(); + } + } + + public bool TryRequestGracefulStop() + { + try + { + if (!_spec.SupportsConsoleGracefulStop + || _process is not { HasExited: false } process + || _processGroupId <= 0) + { + return false; + } + + return _consoleControl.TrySendBreak(process.Id, _processGroupId); + } + catch + { + // The process may have exited between the state check and the + // control request. Graceful-stop attempts never escape Stop(). + return false; + } + } + + public bool CloseMainWindow() => RequireProcess().CloseMainWindow(); + + public void Kill() => RequireProcess().Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout); + + public void Dispose() + { + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + } + + private Process RequireProcess() => _process + ?? throw new InvalidOperationException("The child process has not started."); + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} + +internal interface IWindowsConsoleControl +{ + bool TrySendBreak(int childProcessId, int childProcessGroupId); +} + +internal sealed class WindowsConsoleControl : IWindowsConsoleControl +{ + private const uint CtrlBreakEvent = 1; + + internal static WindowsConsoleControl Instance { get; } = new(); + + private WindowsConsoleControl() + { + } + + public bool TrySendBreak(int childProcessId, int childProcessGroupId) + { + if (!OperatingSystem.IsWindows() + || childProcessId <= 0 + || childProcessGroupId <= 0) + { + return false; + } + + lock (WindowsConsoleSynchronization.Gate) + { + bool attachedHere = false; + try + { + uint[] processes = new uint[1]; + if (Native.GetConsoleProcessList(processes, 1) == 0) + { + if (!Native.AttachConsole((uint)childProcessId)) + { + return false; + } + + attachedHere = true; + } + + return Native.GenerateConsoleCtrlEvent( + CtrlBreakEvent, + (uint)childProcessGroupId); + } + catch + { + return false; + } + finally + { + if (attachedHere) + { + _ = Native.FreeConsole(); + } + } + } + } + + private static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GenerateConsoleCtrlEvent( + uint controlEvent, + uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + } +} + +/// +/// A process can be attached to only one console. Child creation and targeted +/// control-event attachment therefore share one process-wide gate. +/// +internal static class WindowsConsoleSynchronization +{ + internal static object Gate { get; } = new(); +} + +internal sealed class WindowsProcessStartResult : IDisposable +{ + private readonly SafeKernelHandle _processHandle; + private readonly SafeKernelHandle _threadHandle; + private SafeFileHandle? _standardInput; + private bool _resumed; + + internal WindowsProcessStartResult( + int processId, + SafeKernelHandle processHandle, + SafeKernelHandle threadHandle, + SafeFileHandle standardInput) + { + ProcessId = processId; + _processHandle = processHandle; + _threadHandle = threadHandle; + _standardInput = standardInput; + } + + internal int ProcessId { get; } + + internal TextWriter TakeStandardInput() + { + SafeFileHandle handle = _standardInput + ?? throw new InvalidOperationException("Standard input was already claimed."); + var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false); + _standardInput = null; + try + { + return new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + } + catch + { + stream.Dispose(); + throw; + } + } + + internal void Resume() + { + if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be resumed."); + } + + _resumed = true; + } + + internal void Terminate() + { + if (!_processHandle.IsInvalid) + { + _ = WindowsProcessNative.TerminateProcess(_processHandle, 74); + } + } + + public void Dispose() + { + if (!_resumed) + { + Terminate(); + } + + _standardInput?.Dispose(); + _threadHandle.Dispose(); + _processHandle.Dispose(); + } +} + +internal static class WindowsProcessNative +{ + private const uint CreateSuspended = 0x00000004; + private const uint CreateNewProcessGroup = 0x00000200; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint StartfUseStdHandles = 0x00000100; + private const short SwHide = 0; + private const uint HandleFlagInherit = 0x00000001; + private const uint DuplicateSameAccess = 0x00000002; + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002); + + internal static WindowsProcessStartResult Start(LauncherProcessSpec spec) + { + ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath); + ArgumentNullException.ThrowIfNull(spec.Arguments); + + lock (WindowsConsoleSynchronization.Gate) + { + bool allocatedConsole = false; + try + { + // An Avalonia launcher started from Explorer has no console. + // CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a + // console-less group cannot receive GenerateConsoleCtrlEvent. + // Allocate one only for the creation transaction, hide it, + // let the group leader inherit it, then detach the launcher. + // Each such child consequently owns a distinct console as + // well as a distinct process group. + if (!HasConsole()) + { + if (!AllocConsole()) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher could not allocate the child console."); + } + + allocatedConsole = true; + IntPtr consoleWindow = GetConsoleWindow(); + if (consoleWindow != IntPtr.Zero) + { + _ = ShowWindow(consoleWindow, SwHide); + } + } + + return StartCore(spec); + } + finally + { + if (allocatedConsole) + { + _ = FreeConsole(); + } + } + } + } + + private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec) + { + SafeFileHandle? parentInput = null; + try + { + using SafeFileHandle childInput = CreateChildInputPipe( + out SafeFileHandle createdParentInput); + parentInput = createdParentInput; + using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle); + using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle); + using var attributes = new ProcessThreadAttributeList( + childInput.DangerousGetHandle(), + childOutput.DangerousGetHandle(), + childError.DangerousGetHandle()); + + var startup = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = childInput.DangerousGetHandle(), + StandardOutput = childOutput.DangerousGetHandle(), + StandardError = childError.DangerousGetHandle(), + }, + AttributeList = attributes.Pointer, + }; + string executable = ResolveExecutable(spec.ExecutablePath); + string commandLineText = BuildCommandLine(executable, spec.Arguments); + var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1); + string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory) + ? null + : Path.GetFullPath(spec.WorkingDirectory); + + if (!CreateProcessW( + executable, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + inheritHandles: true, + CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent, + IntPtr.Zero, + workingDirectory, + ref startup, + out ProcessInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be created."); + } + + var processHandle = new SafeKernelHandle( + information.Process, + ownsHandle: true); + var threadHandle = new SafeKernelHandle( + information.Thread, + ownsHandle: true); + try + { + var result = new WindowsProcessStartResult( + checked((int)information.ProcessId), + processHandle, + threadHandle, + parentInput); + parentInput = null; + return result; + } + catch + { + _ = TerminateProcess(processHandle, 74); + threadHandle.Dispose(); + processHandle.Dispose(); + throw; + } + } + finally + { + parentInput?.Dispose(); + } + } + + private static bool HasConsole() + { + uint[] processes = new uint[1]; + return GetConsoleProcessList(processes, 1) != 0; + } + + internal static uint ResumeThread(SafeKernelHandle thread) => + NativeResumeThread(thread); + + internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) => + NativeTerminateProcess(process, exitCode); + + internal static string BuildCommandLine( + string executable, + IReadOnlyList arguments) + { + var builder = new StringBuilder(); + AppendQuotedArgument(builder, executable); + foreach (string argument in arguments) + { + ArgumentNullException.ThrowIfNull(argument); + builder.Append(' '); + AppendQuotedArgument(builder, argument); + } + + return builder.ToString(); + } + + private static void AppendQuotedArgument(StringBuilder builder, string value) + { + builder.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + builder.Append('\\', backslashes * 2 + 1); + builder.Append('"'); + backslashes = 0; + continue; + } + + builder.Append('\\', backslashes); + backslashes = 0; + builder.Append(character); + } + + builder.Append('\\', backslashes * 2); + builder.Append('"'); + } + + private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stdin pipe could not be created."); + } + + var child = new SafeFileHandle(read, ownsHandle: true); + parentInput = new SafeFileHandle(write, ownsHandle: true); + if (!SetHandleInformation( + parentInput, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentInput.Dispose(); + throw new Win32Exception(error, + "The launcher child stdin pipe could not be isolated."); + } + + return child; + } + + private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle) + { + IntPtr source = GetStdHandle(standardHandle); + if (source != IntPtr.Zero && source != new IntPtr(-1)) + { + IntPtr current = GetCurrentProcess(); + if (DuplicateHandle( + current, + source, + current, + out IntPtr duplicate, + 0, + inheritHandle: true, + DuplicateSameAccess)) + { + return new SafeKernelHandle(duplicate, ownsHandle: true); + } + } + + IntPtr nul = CreateFileW( + "NUL", + GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + if (nul == IntPtr.Zero || nul == new IntPtr(-1)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child fallback output handle could not be opened."); + } + + var handle = new SafeKernelHandle(nul, ownsHandle: true); + if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit)) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, + "The launcher child fallback output handle could not be inherited."); + } + + return handle; + } + + private static string ResolveExecutable(string executable) + { + if (Path.IsPathFullyQualified(executable)) + { + return Path.GetFullPath(executable); + } + + var buffer = new StringBuilder(32_768); + uint length = SearchPathW( + null, + executable, + null, + (uint)buffer.Capacity, + buffer, + IntPtr.Zero); + if (length == 0 || length >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Launcher child executable '{executable}' was not found."); + } + + return Path.GetFullPath(buffer.ToString()); + } + + private sealed class ProcessThreadAttributeList : IDisposable + { + private IntPtr _pointer; + private IntPtr _handles; + private bool _initialized; + + internal ProcessThreadAttributeList(params IntPtr[] handles) + { + nuint size = 0; + _ = InitializeProcThreadAttributeList( + IntPtr.Zero, + 1, + 0, + ref size); + _pointer = Marshal.AllocHGlobal(checked((nint)size)); + if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child handle list could not be initialized."); + } + _initialized = true; + + _handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size); + for (int index = 0; index < handles.Length; index++) + { + Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]); + } + + if (!UpdateProcThreadAttribute( + _pointer, + 0, + ProcThreadAttributeHandleList, + _handles, + checked((nuint)(handles.Length * IntPtr.Size)), + IntPtr.Zero, + IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child inherited-handle list could not be set."); + } + } + + internal IntPtr Pointer => _pointer; + + public void Dispose() + { + if (_pointer != IntPtr.Zero) + { + if (_initialized) + { + DeleteProcThreadAttributeList(_pointer); + _initialized = false; + } + Marshal.FreeHGlobal(_pointer); + _pointer = IntPtr.Zero; + } + + if (_handles != IntPtr.Zero) + { + Marshal.FreeHGlobal(_handles); + _handles = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + internal int Size; + internal string? Reserved; + internal string? Desktop; + internal string? Title; + internal int X; + internal int Y; + internal int XSize; + internal int YSize; + internal int XCountChars; + internal int YCountChars; + internal int FillAttribute; + internal uint Flags; + internal short ShowWindow; + internal short Reserved2Size; + internal IntPtr Reserved2; + internal IntPtr StandardInput; + internal IntPtr StandardOutput; + internal IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + internal StartupInfo StartupInfo; + internal IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + internal IntPtr Process; + internal IntPtr Thread; + internal uint ProcessId; + internal uint ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out IntPtr readPipe, + out IntPtr writePipe, + ref SecurityAttributes pipeAttributes, + uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation( + SafeHandle handle, + uint mask, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr window, int commandShow); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr sourceProcess, + IntPtr sourceHandle, + IntPtr targetProcess, + out IntPtr targetHandle, + uint desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint options); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint SearchPathW( + string? path, + string fileName, + string? extension, + uint bufferLength, + StringBuilder buffer, + IntPtr filePart); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + nuint size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)] + private static extern uint NativeResumeThread(SafeKernelHandle thread); + + [DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool NativeTerminateProcess( + SafeKernelHandle process, + uint exitCode); +} + +internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + internal SafeKernelHandle(IntPtr handle, bool ownsHandle) + : base(ownsHandle) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() => CloseHandle(handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 4c3ffe16..5a98b795 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet : new LauncherProcessSpec( paths.GraphicalHostPath, ["--session-config", configFilePath], - paths.WorkingDirectory); + paths.WorkingDirectory, + SupportsConsoleGracefulStop: false); } public LauncherProcessSpec CreateProbeSpec(string configFilePath) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 350260b2..1b02f993 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs new file mode 100644 index 00000000..1f1c9164 --- /dev/null +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -0,0 +1,248 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher; + +internal enum LauncherStartupMode +{ + Desktop, + VerifyPublish, + SelfUpdateHelper, + SelfUpdateConfirmation, +} + +/// +/// Immutable, process-local launcher inputs. Parsing happens before any +/// launcher owner is constructed so every owner receives the same exact path +/// set and the test-feed URI can reach only the updater composition. +/// +internal sealed class LauncherStartupOptions +{ + private readonly IReadOnlyList _publicArguments; + + private LauncherStartupOptions( + LauncherStartupMode mode, + ApplicationPathSet paths, + Uri updateManifestUri, + IReadOnlyList publicArguments) + { + Mode = mode; + Paths = paths; + UpdateManifestUri = updateManifestUri; + _publicArguments = Array.AsReadOnly(publicArguments.ToArray()); + } + + internal LauncherStartupMode Mode { get; } + + internal ApplicationPathSet Paths { get; } + + internal Uri UpdateManifestUri { get; } + + /// + /// The validated public option suffix. LA10 passes this suffix through its + /// helper and confirmation processes so an isolated self-update cannot + /// fall back to canonical user roots or the production feed. + /// + internal IReadOnlyList PublicArguments => _publicArguments; + + internal static LauncherStartupOptions Parse( + IReadOnlyList arguments, + Func? resolveDefaultPaths = null) + { + ArgumentNullException.ThrowIfNull(arguments); + resolveDefaultPaths ??= () => ApplicationPathSet.Resolve(); + + (LauncherStartupMode mode, int publicStart) = ReadMode(arguments); + string[] publicArguments = arguments.Skip(publicStart).ToArray(); + + if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal)) + { + if (mode != LauncherStartupMode.Desktop + || publicArguments.Length != 1 + || !string.Equals( + publicArguments[0], + "--verify-publish", + StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + "--verify-publish must be the only launcher argument."); + } + + return new LauncherStartupOptions( + LauncherStartupMode.VerifyPublish, + // The publish probe returns before this value is observed. A + // non-resolving sentinel keeps the probe display- and + // user-profile-free even under a deliberately broken runtime. + new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null), + ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + string? configDirectory = null; + string? dataDirectory = null; + string? cacheDirectory = null; + Uri? updateManifestUri = null; + + for (int index = 0; index < publicArguments.Length; index += 2) + { + string name = publicArguments[index]; + if (index + 1 >= publicArguments.Length + || publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a value."); + } + + string value = publicArguments[index + 1]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a non-empty value."); + } + + switch (name) + { + case "--config-dir": + SetDirectoryOnce(ref configDirectory, value, name); + break; + case "--data-dir": + SetDirectoryOnce(ref dataDirectory, value, name); + break; + case "--cache-dir": + SetDirectoryOnce(ref cacheDirectory, value, name); + break; + case "--update-manifest-uri": + if (updateManifestUri is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)) + { + throw new LauncherStartupOptionsException( + "--update-manifest-uri must be an absolute URI."); + } + + if (parsed.Scheme != Uri.UriSchemeHttps + && !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI must use HTTPS " + + "(loopback HTTP is test-only)."); + } + + if (!string.IsNullOrEmpty(parsed.UserInfo)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain user information."); + } + + updateManifestUri = parsed; + break; + default: + throw new LauncherStartupOptionsException( + $"Unknown launcher option '{name}'."); + } + } + + int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory } + .Count(path => path is not null); + if (suppliedRoots is > 0 and < 3) + { + throw new LauncherStartupOptionsException( + "--config-dir, --data-dir, and --cache-dir must be supplied together."); + } + + ApplicationPathSet paths = suppliedRoots == 3 + ? new ApplicationPathSet( + configDirectory!, + dataDirectory!, + cacheDirectory!, + LegacyConfigDirectory: null) + : resolveDefaultPaths(); + return new LauncherStartupOptions( + mode, + paths, + updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + private static (LauncherStartupMode Mode, int PublicStart) ReadMode( + IReadOnlyList arguments) + { + if (arguments.Count == 0) + { + return (LauncherStartupMode.Desktop, 0); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.HelperArgument, + StringComparison.Ordinal)) + { + // Malformed internal invocations are rejected by the bootstrap + // with EX_USAGE. Do not reinterpret their operands as public + // options while resolving the manager they need to report that. + return ( + LauncherStartupMode.SelfUpdateHelper, + arguments.Count >= 4 ? 4 : arguments.Count); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.ConfirmArgument, + StringComparison.Ordinal)) + { + return ( + LauncherStartupMode.SelfUpdateConfirmation, + arguments.Count >= 2 ? 2 : arguments.Count); + } + + return (LauncherStartupMode.Desktop, 0); + } + + private static void SetDirectoryOnce( + ref string? destination, + string value, + string option) + { + if (destination is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Path.IsPathFullyQualified(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' must be an absolute path."); + } + + try + { + destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value)); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' is not a valid absolute path.", + ex); + } + } +} + +internal sealed class LauncherStartupOptionsException : Exception +{ + internal LauncherStartupOptionsException(string message) + : base(message) + { + } + + internal LauncherStartupOptionsException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj new file mode 100644 index 00000000..e5c9a2c7 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + enable + latest + true + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs new file mode 100644 index 00000000..dc047cdf --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +if (args.Length < 4 + || args[0] != "wait-for-break" + || string.IsNullOrWhiteSpace(args[1]) + || string.IsNullOrWhiteSpace(args[2])) +{ + return 64; +} + +string readyPath = Path.GetFullPath(args[1]); +string breakPath = Path.GetFullPath(args[2]); +string label = args[3]; +string[] payloadArguments = args.Skip(4).ToArray(); +using var stopped = new ManualResetEventSlim(false); +ConsoleCancelEventHandler handler = (_, eventArgs) => +{ + if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak) + { + return; + } + + eventArgs.Cancel = true; + try + { + File.WriteAllText(breakPath, label); + } + finally + { + stopped.Set(); + } +}; +Console.CancelKeyPress += handler; +try +{ + string stdin = Console.In.ReadToEnd(); + Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!); + File.WriteAllText( + readyPath, + JsonSerializer.Serialize(new + { + processId = Environment.ProcessId, + label, + arguments = payloadArguments, + stdinLength = stdin.Length, + stdinLineCount = stdin.Count(character => character == '\n'), + })); + + return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75; +} +finally +{ + Console.CancelKeyPress -= handler; +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj new file mode 100644 index 00000000..441bc38a --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj @@ -0,0 +1,14 @@ + + + WinExe + net10.0 + enable + enable + latest + true + true + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs new file mode 100644 index 00000000..22ad9230 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using AcDream.Launcher.Core.Launching; + +if (args.Length != 3) +{ + return 64; +} + +string resultPath = Path.GetFullPath(args[0]); +string dotnetPath = args[1]; +string childAssembly = Path.GetFullPath(args[2]); +string root = Path.Combine( + Path.GetDirectoryName(resultPath)!, + "consoleless-children"); +Directory.CreateDirectory(root); +string targetReady = Path.Combine(root, "target.ready.json"); +string targetBreak = Path.Combine(root, "target.break"); +string siblingReady = Path.Combine(root, "sibling.ready.json"); +string siblingBreak = Path.Combine(root, "sibling.break"); + +bool parentHadConsoleBefore = HasConsole(); +using var target = new LauncherProcessSupervisor(); +using var sibling = new LauncherProcessSupervisor(); +try +{ + target.Start( + Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"), + "stdin-from-consoleless-parent"); + sibling.Start( + Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"), + password: null); + WaitForFile(targetReady, target); + WaitForFile(siblingReady, sibling); + bool parentHadConsoleAfterStarts = HasConsole(); + + target.Stop(TimeSpan.FromSeconds(10)); + bool siblingUnaffected = sibling.State != LauncherSessionState.Exited + && !File.Exists(siblingBreak); + sibling.Stop(TimeSpan.FromSeconds(10)); + + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + parentHadConsoleAfterStarts, + targetExitCode = target.ExitCode, + siblingExitCode = sibling.ExitCode, + targetBreakObserved = File.Exists(targetBreak), + siblingBreakObserved = File.Exists(siblingBreak), + siblingUnaffected, + })); + return 0; +} +catch (Exception error) +{ + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + error = error.GetType().Name + ": " + error.Message, + })); + return 1; +} +finally +{ + ForceStop(target); + ForceStop(sibling); +} + +static LauncherProcessSpec Spec( + string dotnetPath, + string childAssembly, + string ready, + string breakMarker, + string label) => + new( + dotnetPath, + [ + childAssembly, + "wait-for-break", + ready, + breakMarker, + label, + "argument with spaces", + ]); + +static void WaitForFile(string path, LauncherProcessSupervisor supervisor) +{ + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (supervisor.State == LauncherSessionState.Exited) + { + throw new InvalidOperationException( + $"Child exited early with {supervisor.ExitCode}."); + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException("Child did not become ready."); + } + + Thread.Sleep(20); + } +} + +static void ForceStop(LauncherProcessSupervisor supervisor) +{ + try + { + supervisor.Stop(TimeSpan.Zero); + } + catch + { + } +} + +static bool HasConsole() +{ + uint[] processes = new uint[1]; + return Native.GetConsoleProcessList(processes, 1) != 0; +} + +internal static partial class Native +{ + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); +} diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj index 78fa3e4f..f1677cc9 100644 --- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -25,5 +25,17 @@ false true + + + false + true + + + + false + true + diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index 4ec1431e..ca55ff60 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; using System.Threading; using AcDream.Launcher.Core.Launching; @@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching; public sealed class LauncherProcessSupervisorTests { + [Fact] + public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var factory = new SystemChildProcessFactory(); + using ILauncherChildProcess console = factory.Create( + new LauncherProcessSpec("headless.exe", [])); + using ILauncherChildProcess graphical = factory.Create( + new LauncherProcessSpec( + "graphical.exe", + [], + SupportsConsoleGracefulStop: false)); + + Assert.IsType(console); + Assert.IsType(graphical); + } + [Fact] public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning() { @@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests } } + [Fact] + public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-ctrl-break", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var targetFactory = new RecordingRealChildProcessFactory(); + var siblingFactory = new RecordingRealChildProcessFactory(); + using var target = new LauncherProcessSupervisor(targetFactory); + using var sibling = new LauncherProcessSupervisor(siblingFactory); + string targetReady = Path.Combine(root, "target.ready.json"); + string targetBreak = Path.Combine(root, "target.break"); + string siblingReady = Path.Combine(root, "sibling.ready.json"); + string siblingBreak = Path.Combine(root, "sibling.break"); + string[] exactArguments = + [ + "plain", + "contains spaces", + "quoted-\"value", + "ends-with-backslash\\", + string.Empty, + ]; + + try + { + target.Start( + ConsoleFixtureSpec( + targetReady, + targetBreak, + "target", + exactArguments), + "fixture-input"); + sibling.Start( + ConsoleFixtureSpec( + siblingReady, + siblingBreak, + "sibling", + ["sibling"]), + password: null); + await WaitForFileAsync(targetReady, targetFactory.LastCreated!); + await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!); + + using (JsonDocument ready = JsonDocument.Parse( + await File.ReadAllTextAsync(targetReady))) + { + string[] observed = ready.RootElement + .GetProperty("arguments") + .EnumerateArray() + .Select(value => value.GetString()!) + .ToArray(); + Assert.Equal(exactArguments, observed); + Assert.Equal( + "fixture-input\n".Length, + ready.RootElement.GetProperty("stdinLength").GetInt32()); + Assert.Equal( + 1, + ready.RootElement.GetProperty("stdinLineCount").GetInt32()); + } + + target.Stop(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, target.ExitCode); + Assert.True(File.Exists(targetBreak), + "the target fixture did not observe CTRL_BREAK"); + Assert.Equal(0, targetFactory.LastCreated!.KillCallCount); + Assert.False(siblingFactory.LastCreated!.HasExited); + Assert.False(File.Exists(siblingBreak), + "CTRL_BREAK spilled into the sibling process group"); + + sibling.Stop(TimeSpan.FromSeconds(10)); + Assert.Equal(0, sibling.ExitCode); + Assert.True(File.Exists(siblingBreak)); + Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount); + } + finally + { + targetFactory.LastCreated?.ForceCleanup(); + siblingFactory.LastCreated?.ForceCleanup(); + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-consoleless-parent", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string resultPath = Path.Combine(root, "result.json"); + var startInfo = new ProcessStartInfo + { + FileName = GetConsolelessParentFixturePath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(resultPath); + startInfo.ArgumentList.Add(FindDotnetExecutable()); + startInfo.ArgumentList.Add(GetConsoleFixturePath()); + + try + { + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException( + "The consoleless supervisor fixture did not start."); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + await process.WaitForExitAsync(timeout.Token); + + Assert.True(File.Exists(resultPath), + "the consoleless supervisor fixture did not write its result"); + using JsonDocument result = JsonDocument.Parse( + await File.ReadAllTextAsync(resultPath)); + Assert.Equal(0, process.ExitCode); + Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean()); + Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean()); + Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32()); + Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32()); + Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean()); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + [Fact] public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted() { @@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests private static LauncherProcessSpec Spec() => new("fake-host", ["--session-config", "session.json"]); + private static LauncherProcessSpec ConsoleFixtureSpec( + string ready, + string breakMarker, + string label, + IReadOnlyList exactArguments) => + new( + FindDotnetExecutable(), + [ + GetConsoleFixturePath(), + "wait-for-break", + ready, + breakMarker, + label, + .. exactArguments, + ]); + private static string FindDotnetExecutable() => // PATH-based resolution: .NET Core's Process.Start searches PATH // for a bare filename when UseShellExecute is false, on both @@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests // because this test is itself running under `dotnet test`. OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + private static string GetConsoleFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll"); + } + + private static string GetConsolelessParentFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe"); + } + + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("Repository root was not found."); + } + + private static async Task WaitForFileAsync( + string path, + RecordingChildProcess child) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (child.HasExited) + { + throw new InvalidOperationException( + $"Console fixture exited early with {child.ExitCode}."); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException("Console fixture did not become ready."); + } + + await Task.Delay(20); + } + } + + private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory + { + private readonly SystemChildProcessFactory _inner = new(); + + internal RecordingChildProcess? LastCreated { get; private set; } + + public ILauncherChildProcess Create(LauncherProcessSpec spec) + { + LastCreated = new RecordingChildProcess(_inner.Create(spec)); + return LastCreated; + } + } + + private sealed class RecordingChildProcess(ILauncherChildProcess inner) + : ILauncherChildProcess + { + public int KillCallCount { get; private set; } + + public bool HasExited => inner.HasExited; + + public int ExitCode => inner.ExitCode; + + public TextWriter StandardInput => inner.StandardInput; + + public event EventHandler? Exited + { + add => inner.Exited += value; + remove => inner.Exited -= value; + } + + public void Start() => inner.Start(); + + public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop(); + + public bool CloseMainWindow() => inner.CloseMainWindow(); + + public void Kill() + { + KillCallCount++; + inner.Kill(); + } + + public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout); + + public void ForceCleanup() + { + try + { + if (!HasExited) + { + inner.Kill(); + _ = inner.WaitForExit(TimeSpan.FromSeconds(5)); + } + } + catch + { + } + } + + public void Dispose() => inner.Dispose(); + } + private sealed class FakeChildProcessFactory( bool exitsWithinStopTimeout, bool exitDuringStart = false, diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs index 3d146ffd..a8442f65 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs @@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable Assert.Equal( headless, set.CreateProbeSpec("session.json").ExecutablePath); + Assert.False( + set.CreatePlaySpec(LaunchMode.Gui, "session.json") + .SupportsConsoleGracefulStop); + Assert.False( + set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json") + .SupportsConsoleGracefulStop); + Assert.True( + set.CreatePlaySpec(LaunchMode.Headless, "session.json") + .SupportsConsoleGracefulStop); + Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop); } [Fact] diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs new file mode 100644 index 00000000..ff3bb24c --- /dev/null +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -0,0 +1,182 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Tests; + +public sealed class LauncherStartupOptionsTests +{ + [Fact] + public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths() + { + string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation"); + string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar; + string data = Path.Combine(root, "data", ".", "state"); + string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar; + bool defaultResolverCalled = false; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", config, + "--data-dir", data, + "--cache-dir", cache, + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ], + () => + { + defaultResolverCalled = true; + throw new InvalidOperationException("canonical path resolver was touched"); + }); + + Assert.False(defaultResolverCalled); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)), + options.Paths.ConfigDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)), + options.Paths.DataDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)), + options.Paths.CacheDirectory); + Assert.Null(options.Paths.LegacyConfigDirectory); + Assert.Equal( + "http://127.0.0.1:43119/manifest.json", + options.UpdateManifestUri.AbsoluteUri); + Assert.Equal(LauncherStartupMode.Desktop, options.Mode); + } + + [Fact] + public void NoOverridesResolveDefaultsExactlyOnce() + { + var expected = new ApplicationPathSet("config", "data", "cache", "legacy"); + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [], + () => + { + calls++; + return expected; + }); + + Assert.Same(expected, options.Paths); + Assert.Equal(1, calls); + Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri); + } + + [Fact] + public void VerifyPublishIsExclusiveAndDoesNotResolvePaths() + { + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--verify-publish"], + () => + { + calls++; + throw new InvalidOperationException(); + }); + + Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode); + Assert.Equal(0, calls); + Assert.Throws(() => + LauncherStartupOptions.Parse( + ["--verify-publish", "--cache-dir", Path.GetTempPath()])); + } + + [Theory] + [MemberData(nameof(InvalidArguments))] + public void RejectsInvalidPublicArguments(string[] arguments) + { + Assert.Throws(() => + LauncherStartupOptions.Parse( + arguments, + () => new ApplicationPathSet("c", "d", "x", null))); + } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://localhost:8123/manifest.json")] + [InlineData("http://[::1]:8123/manifest.json")] + public void AcceptsHttpsAndLoopbackHttpFeeds(string value) + { + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", value], + () => new ApplicationPathSet("c", "d", "x", null)); + + Assert.Equal(new Uri(value), options.UpdateManifestUri); + } + + [Fact] + public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix() + { + string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "http://localhost:8123/manifest.json", + ]; + string[] helper = + [ + LauncherSelfUpdateBootstrap.HelperArgument, + "123", + root, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + string[] confirmation = + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + + LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper); + LauncherStartupOptions confirmationOptions = + LauncherStartupOptions.Parse(confirmation); + + Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode); + Assert.Equal( + LauncherStartupMode.SelfUpdateConfirmation, + confirmationOptions.Mode); + Assert.Equal(suffix, helperOptions.PublicArguments); + Assert.Equal(suffix, confirmationOptions.PublicArguments); + Assert.Equal(helperOptions.Paths, confirmationOptions.Paths); + Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); + } + + public static TheoryData InvalidArguments() + { + string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); + var data = new TheoryData(); + data.Add(["--unknown", "value"]); + data.Add(["--config-dir"]); + data.Add(["--config-dir", "relative"]); + data.Add(["--config-dir", absolute]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + ]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + "--cache-dir", absolute, + "--cache-dir", absolute, + ]); + data.Add( + ["--update-manifest-uri", "http://updates.example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "not-a-uri"]); + data.Add( + [ + "--update-manifest-uri", "https://example.test/a", + "--update-manifest-uri", "https://example.test/b", + ]); + return data; + } +} diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 new file mode 100644 index 00000000..98d129eb --- /dev/null +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -0,0 +1,333 @@ +<# +.SYNOPSIS + Creates deterministic isolated Campaign LA A/B update feeds. + +.DESCRIPTION + Packages caller-supplied published client and launcher roots for win-x64 + and linux-x64, adds a deterministic release marker, calculates the exact + LA10 SHA-256/size manifest fields, and emits a loopback-only static server + plus a local A/B selector. It never downloads, connects, edits a payload + source, or writes outside -OutputDirectory. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB, + [string]$VersionA = '1.0.1-la11.a', + [string]$VersionB = '1.0.1-la11.b', + [string]$MinimumLauncherVersion = '1.0.0', + [int]$Port = 43119, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.' +} +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } +$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' +if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or + $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) { + throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.' +} +$parsedVersionA = [semver]$VersionA +$parsedVersionB = [semver]$VersionB +$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion +if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) { + throw 'VersionB must be newer than VersionA.' +} +if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) { + throw 'MinimumLauncherVersion must not be newer than VersionA.' +} + +$sources = [ordered]@{ + 'A-client-win-x64' = $ClientWinX64DirectoryA + 'A-launcher-win-x64' = $LauncherWinX64DirectoryA + 'A-client-linux-x64' = $ClientLinuxX64DirectoryA + 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA + 'B-client-win-x64' = $ClientWinX64DirectoryB + 'B-launcher-win-x64' = $LauncherWinX64DirectoryB + 'B-client-linux-x64' = $ClientLinuxX64DirectoryB + 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB +} +foreach ($key in @($sources.Keys)) { + $source = [string]$sources[$key] + if (-not [IO.Path]::IsPathFullyQualified($source)) { + throw "Payload source '$key' must be absolute." + } + $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source)) + $sources[$key] = $source + if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { + throw "Payload source '$key' does not exist: $source" + } +} + +function Require-PayloadFile([string]$Key, [string]$Name) { + if ($DryRun) { return } + if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) { + throw "Payload source '$Key' is missing root file '$Name'." + } +} +foreach ($release in @('A', 'B')) { + Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' + Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' + Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' +} + +if (Test-Path -LiteralPath $OutputDirectory) { + if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { + throw '-OutputDirectory must not already contain files.' + } +} +else { $null = New-Item -ItemType Directory -Path $OutputDirectory } + +if ($DryRun) { + $plan = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture-plan' + outputDirectory = $OutputDirectory + versions = @($VersionA, $VersionB) + minimumLauncherVersion = $MinimumLauncherVersion + port = $Port + sources = $sources + writesOutsideOutputDirectory = $false + externalNetwork = $false + } + $plan | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM + Write-Host "Campaign LA update fixture dry run: $OutputDirectory" + return +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + +function New-DeterministicZip( + [string]$SourceDirectory, + [string]$Destination, + [string]$ReleaseLabel, + [string]$PayloadKind, + [string]$Rid) { + $destinationDirectory = Split-Path -Parent $Destination + $null = New-Item -ItemType Directory -Force -Path $destinationDirectory + $stream = [IO.FileStream]::new( + $Destination, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $true, + [Text.Encoding]::UTF8) + try { + $files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse | + Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') }) + foreach ($file in $files) { + if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Payload contains a reparse point: $($file.FullName)" + } + $relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or + [IO.Path]::IsPathRooted($relative)) { + throw "Payload path escaped its root: $relative" + } + $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $fixedTimestamp + $executable = $relative -ceq 'AcDream.App' -or + $relative -ceq 'acdream-headless' -or + $relative -ceq 'acdream-launcher' -or + $relative.EndsWith('.sh', [StringComparison]::Ordinal) + $mode = if ($executable) { 0x81ED } else { 0x81A4 } + $entry.ExternalAttributes = $mode -shl 16 + $input = [IO.File]::OpenRead($file.FullName) + $output = $entry.Open() + try { $input.CopyTo($output) } + finally { $output.Dispose(); $input.Dispose() } + } + $marker = $archive.CreateEntry( + 'campaign-la-fixture-release.txt', + [IO.Compression.CompressionLevel]::Optimal) + $marker.LastWriteTime = $fixedTimestamp + $marker.ExternalAttributes = 0x81A4 -shl 16 + $writer = [IO.StreamWriter]::new( + $marker.Open(), + [Text.UTF8Encoding]::new($false)) + try { + $writer.NewLine = "`n" + $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n") + } + finally { $writer.Dispose() } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + +function Get-Artifact([string]$Path, [string]$Url) { + $item = Get-Item -LiteralPath $Path + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +$releaseDefinitions = @( + [pscustomobject]@{ Label = 'A'; Version = $VersionA }, + [pscustomobject]@{ Label = 'B'; Version = $VersionB } +) +foreach ($release in $releaseDefinitions) { + $releaseRoot = Join-Path $OutputDirectory $release.Label + foreach ($rid in @('win-x64', 'linux-x64')) { + New-DeterministicZip ` + $sources["$($release.Label)-client-$rid"] ` + (Join-Path $releaseRoot "client-$rid.zip") ` + $release.Label 'client' $rid + New-DeterministicZip ` + $sources["$($release.Label)-launcher-$rid"] ` + (Join-Path $releaseRoot "launcher-$rid.zip") ` + $release.Label 'launcher' $rid + } + $baseUri = "http://127.0.0.1:$Port/$($release.Label)" + $manifest = [ordered]@{ + schemaVersion = 1 + version = $release.Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-win-x64.zip') ` + "$baseUri/client-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-linux-x64.zip') ` + "$baseUri/client-linux-x64.zip" + } + launchers = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-win-x64.zip') ` + "$baseUri/launcher-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-linux-x64.zip') ` + "$baseUri/launcher-linux-x64.zip" + } + } + $manifest | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM +} +Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') ` + -Value 'A' -Encoding ascii -NoNewline + +$server = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][int]$Port +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$prefix = "http://127.0.0.1:$Port/" +$listener = [Net.HttpListener]::new() +$listener.Prefixes.Add($prefix) +$listener.Start() +Write-Host "Campaign LA fixture listening on $prefix" +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + if ($context.Request.HttpMethod -cne 'GET') { + $context.Response.StatusCode = 405 + continue + } + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ($relative -ceq 'manifest.json') { + $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim() + if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' } + $relative = "$active/manifest.json" + } + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + continue + } + $path = [IO.Path]::GetFullPath((Join-Path $Root $relative)) + if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + $context.Response.StatusCode = 404 + continue + } + $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) { + 'application/json' + } else { 'application/zip' } + $context.Response.StatusCode = 200 + $context.Response.Headers['Cache-Control'] = 'no-store' + $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length + $input = [IO.File]::OpenRead($path) + try { $input.CopyTo($context.Response.OutputStream) } + finally { $input.Dispose() } + } + catch { + $context.Response.StatusCode = 500 + Write-Error $_ + } + finally { $context.Response.Close() } + } +} +finally { $listener.Close() } +'@ +$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM + +$selector = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release, + [string]$Root = $PSScriptRoot +) +Set-StrictMode -Version Latest +$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' +Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +Write-Host "Campaign LA fixture active release: $Release" +'@ +$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM + +$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture' + versions = [ordered]@{ A = $VersionA; B = $VersionB } + minimumLauncherVersion = $MinimumLauncherVersion + manifestUri = "http://127.0.0.1:$Port/manifest.json" + loopbackOnly = $true + initialRelease = 'A' + sourceDirectories = $sources + artifacts = $inventory +} +$report | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM +Write-Host "Campaign LA update fixture: $OutputDirectory" diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 new file mode 100644 index 00000000..0a676df1 --- /dev/null +++ b/tools/run-campaign-la-preflight.ps1 @@ -0,0 +1,394 @@ +<# +.SYNOPSIS + Campaign LA11 display-free, connection-free automated preflight. + +.DESCRIPTION + Runs the exact Release and portability ladder used before the launcher + user gate. It never starts App/Headless in connected mode, never opens a + window, never reads credentials, and never bakes retail DATs. All logs and + publishes are contained beneath one logs/campaign-la-gate- + directory. Use -DryRun to emit the complete command matrix without + executing it. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$OutputDirectory, + [switch]$DryRun, + [switch]$IncludeInstalledDat, + [string]$InstalledDatDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA preflight requires PowerShell 7 or newer.' +} + +$Repository = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($Repository)) +if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { + throw "Repository does not contain AcDream.slnx: $Repository" +} +if ($IncludeInstalledDat) { + if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or + -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { + throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.' + } + $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($InstalledDatDirectory)) + foreach ($file in @( + 'client_portal.dat', + 'client_cell_1.dat', + 'client_highres.dat', + 'client_local_English.dat')) { + if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) { + throw "Installed DAT directory is missing $file." + } + } +} + +$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" +} +elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository $OutputDirectory +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +$logsDirectory = Join-Path $OutputDirectory 'commands' +$publishDirectory = Join-Path $OutputDirectory 'publish' +$null = New-Item -ItemType Directory -Force -Path $logsDirectory + +$commandResults = [Collections.Generic.List[object]]::new() +$failures = [Collections.Generic.List[string]]::new() +$startedUtc = [DateTime]::UtcNow + +function Protect-Text([string]$Text) { + if ($null -eq $Text) { return '' } + $protected = $Text + foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrEmpty($value)) { + $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) + } + } + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(--password|-password)(\s+|=)([^\s"'']+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', + '$1$2') + return $protected +} + +function Format-Command([string]$FilePath, [string[]]$Arguments) { + $parts = [Collections.Generic.List[string]]::new() + $parts.Add($FilePath) + foreach ($argument in $Arguments) { + if ($argument -match '[\s"]') { + $parts.Add('"' + $argument.Replace('"', '\"') + '"') + } + else { $parts.Add($argument) } + } + return $parts -join ' ' +} + +function Add-PlannedCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = 'planned' + startedUtc = $null + durationSeconds = 0 + exitCode = $null + stdout = $null + stderr = $null + environmentKeys = @($Environment.Keys | Sort-Object) + }) +} + +function Invoke-GateCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + if ($DryRun) { + Add-PlannedCommand $Name $FilePath $Arguments $Environment + return + } + + $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-' + $stdoutRelative = "commands/$safeName.out.log" + $stderrRelative = "commands/$safeName.err.log" + $stdoutPath = Join-Path $OutputDirectory $stdoutRelative + $stderrPath = Join-Path $OutputDirectory $stderrRelative + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 74 + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $Repository + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($entry in $Environment.GetEnumerator()) { + $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "Could not start $FilePath." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout)) + [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr)) + } + catch { + [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String))) + } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode + stdout = $stdoutRelative + stderr = $stderrRelative + environmentKeys = @($Environment.Keys | Sort-Object) + }) + } + if ($exitCode -ne 0) { + throw "Preflight command '$Name' failed with exit code $exitCode." + } +} + +function Add-InternalCheck([string]$Name, [scriptblock]$Action) { + if ($DryRun) { + $commandResults.Add([ordered]@{ + name = $Name; command = ''; status = 'planned' + startedUtc = $null; durationSeconds = 0; exitCode = $null + stdout = $null; stderr = $null; environmentKeys = @() + }) + return + } + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 0 + try { & $Action } + catch { $exitCode = 1; throw } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name; command = '' + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @() + }) + } +} + +function Invoke-DotNet([string]$Name, [string[]]$Arguments) { + Invoke-GateCommand $Name 'dotnet' $Arguments +} + +$portableBuildProjects = @( + 'src/AcDream.Platform/AcDream.Platform.csproj', + 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj', + 'src/AcDream.Bake/AcDream.Bake.csproj', + 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj', + 'src/AcDream.Core/AcDream.Core.csproj', + 'src/AcDream.Core.Net/AcDream.Core.Net.csproj', + 'src/AcDream.Content/AcDream.Content.csproj', + 'src/AcDream.Runtime/AcDream.Runtime.csproj', + 'src/AcDream.Headless/AcDream.Headless.csproj' +) +$portableTestProjects = @( + 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj', + 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj', + 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj', + 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj', + 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj', + 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj' +) + +try { + Invoke-DotNet 'release-build' @( + 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-DotNet 'release-tests-serial' @( + 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', + '--', 'RunConfiguration.MaxCpuCount=1') + Invoke-DotNet 'focused-launcher-updater-core' @( + 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~Updates') + Invoke-DotNet 'focused-launcher-updater-ui' @( + 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests') + + foreach ($project in $portableBuildProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-build-$leaf" @( + 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1') + } + foreach ($project in $portableTestProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-test-$leaf" @( + 'test', $project, '-c', 'Release', '--no-build', '--nologo', + '--', 'RunConfiguration.MaxCpuCount=1') + } + + foreach ($rid in @('win-x64', 'linux-x64')) { + $destination = Join-Path $publishDirectory $rid + Invoke-DotNet "publish-launcher-$rid" @( + 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj', + '-c', 'Release', '-r', $rid, '--self-contained', 'true', + '-p:PublishSingleFile=true', '-o', $destination, '--nologo') + Add-InternalCheck "publish-contract-$rid" { + $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' } + foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) { + if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) { + throw "$rid publish is missing $name." + } + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) { + throw "$rid launcher publish is not single-file." + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) { + throw "$rid bake publish is not single-file." + } + if (-not $IsWindows -and $rid -eq 'linux-x64') { + $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher')) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'linux-x64 launcher is not executable.' + } + } + } + } + + $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' } + $nativeSuffix = if ($IsWindows) { '.exe' } else { '' } + $nativeRoot = Join-Path $publishDirectory $nativeRid + $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' } + $bogusEnvironment = @{ + DOTNET_ROOT = $bogusRoot + DOTNET_ROOT_X64 = $bogusRoot + DOTNET_MULTILEVEL_LOOKUP = '0' + } + Invoke-GateCommand 'native-launcher-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") ` + @('--verify-publish') $bogusEnvironment + Invoke-GateCommand 'native-bake-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-bake$nativeSuffix") ` + @('--help') $bogusEnvironment + + if ($IncludeInstalledDat) { + $datEnvironment = @{ + ACDREAM_DAT_DIR = $InstalledDatDirectory + ACDREAM_PROBE_LIVE_MOUNT = '1' + } + $datResults = Join-Path $OutputDirectory 'installed-dat-results' + Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests', + '--results-directory', $datResults, + '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment + Add-InternalCheck 'installed-dat-character-management-require-pass' { + $trx = Join-Path $datResults 'character-management.trx' + if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) { + throw 'CharacterManagementLiveDatTests did not produce a TRX result.' + } + [xml]$result = Get-Content -LiteralPath $trx -Raw + $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome }) + if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or + @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) { + throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')." + } + } + Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @( + 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment + Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment + } +} +catch { + $failures.Add((Protect-Text ($_ | Out-String)).Trim()) +} +finally { + $finishedUtc = [DateTime]::UtcNow + $head = (& git -C $Repository rev-parse HEAD).Trim() + $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) + $artifacts = @() + if (-not $DryRun) { + $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + } + $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' }) + $report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-automated-preflight' + dryRun = [bool]$DryRun + success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) + repository = $Repository + head = $head + dirty = ($dirtyLines.Count -gt 0) + dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) + platform = [ordered]@{ + os = [Runtime.InteropServices.RuntimeInformation]::OSDescription + architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier + framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription + powershell = $PSVersionTable.PSVersion.ToString() + } + startedUtc = $startedUtc.ToString('O') + finishedUtc = $finishedUtc.ToString('O') + durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3) + installedDatIncluded = [bool]$IncludeInstalledDat + commands = @($commandResults) + failures = @($failures) + redaction = [ordered]@{ + applied = $true + environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + credentialArgumentsAllowed = $false + } + artifacts = $artifacts + } + $reportPath = Join-Path $OutputDirectory 'report.json' + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM + Write-Host "Campaign LA preflight report: $reportPath" + if (-not $report.success) { exit 1 } +} diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 new file mode 100644 index 00000000..834ff031 --- /dev/null +++ b/tools/test-campaign-la-session-status.ps1 @@ -0,0 +1,346 @@ +<# +.SYNOPSIS + Strict Campaign LA v1 session-status and terminal-process validator. + +.DESCRIPTION + Validates exact JSONL property sets and property order, lifecycle order for + probe/guiSelect/gui/headless, terminal semantics, plugin expectations, + credential redaction, and absence of launcher child-process leaks. The + report contains hashes and event names only; it does not copy account, + character, command, plugin-error, or other payload text. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$StatusFile, + [Parameter(Mandatory = $true)] + [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [string]$ExpectedSessionId, + [string[]]$ExpectedPlugin = @(), + [switch]$ExpectNoEnteredWorld, + [switch]$AllowPluginFailure, + [switch]$AllowLoginCommandFailure, + [switch]$AllowLauncherChildren, + [string[]]$ForbiddenEnvironmentVariable = @( + 'ACDREAM_TEST_PASS', + 'ACDREAM_LA_GATE_SECRET'), + [string]$ReportPath, + [int]$ProcessExitWaitSeconds = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA status validation requires PowerShell 7 or newer.' +} +if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { + throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' +} +if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { + $StatusFile = [IO.Path]::GetFullPath($StatusFile) +} +if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { + throw "Status file does not exist: $StatusFile" +} +if ([string]::IsNullOrWhiteSpace($ReportPath)) { + $ReportPath = "$StatusFile.validation.json" +} +elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + $ReportPath = [IO.Path]::GetFullPath($ReportPath) +} + +$exactFields = @{ + started = @('v', 'e', 't', 'sessionId') + connected = @('v', 'e', 't', 'sessionId') + characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') + enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') + pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') + pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') + loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') + disconnected = @('v', 'e', 't', 'sessionId', 'reason') + exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') +} +$failures = [Collections.Generic.List[string]]::new() +$eventNames = [Collections.Generic.List[string]]::new() +$loadedPlugins = [Collections.Generic.List[string]]::new() +$sessionId = $null +$previousTimestamp = [DateTimeOffset]::MinValue +$terminalSeen = $false + +function Get-Properties([Text.Json.JsonElement]$Element) { + $properties = [Collections.Generic.List[object]]::new() + foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } + return @($properties) +} + +function Assert-String( + [Text.Json.JsonElement]$Root, + [string]$Name, + [bool]$AllowEmpty = $false) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { + throw "field '$Name' is not a string" + } + $text = $value.GetString() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { + throw "field '$Name' is empty" + } + return $text +} + +function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetInt32() +} + +function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetUInt32() +} + +$lines = @(Get-Content -LiteralPath $StatusFile) +if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } +for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $lineNumber = $lineIndex + 1 + $line = $lines[$lineIndex] + if ([string]::IsNullOrWhiteSpace($line)) { + $failures.Add("line $lineNumber is empty") + continue + } + foreach ($variable in $ForbiddenEnvironmentVariable) { + $secret = [Environment]::GetEnvironmentVariable($variable) + if (-not [string]::IsNullOrEmpty($secret) -and + $line.Contains($secret, [StringComparison]::Ordinal)) { + $failures.Add("line $lineNumber contains the value of forbidden environment variable $variable") + } + } + if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { + $failures.Add("line $lineNumber contains a credential-like JSON field") + } + + $document = $null + try { + $document = [Text.Json.JsonDocument]::Parse($line) + $root = $document.RootElement + if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'root is not an object' + } + $properties = @(Get-Properties $root) + $names = @($properties | ForEach-Object { $_.Name }) + if (@($names | Sort-Object -Unique).Count -ne $names.Count) { + throw 'object contains duplicate fields' + } + $eventName = Assert-String $root 'e' + if (-not $exactFields.ContainsKey($eventName)) { + throw "event '$eventName' is not in the v1 vocabulary" + } + $expected = $exactFields[$eventName] + if ($names.Count -ne $expected.Count -or + [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { + throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" + } + if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } + $timestampText = Assert-String $root 't' + $timestamp = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $timestampText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { + throw 'field t is not an exact UTC round-trip timestamp' + } + if ($timestamp -lt $previousTimestamp) { + throw 'timestamp order moved backwards' + } + $previousTimestamp = $timestamp + $lineSessionId = Assert-String $root 'sessionId' + if ($null -eq $sessionId) { $sessionId = $lineSessionId } + if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $lineSessionId -cne $ExpectedSessionId) { + throw 'sessionId does not match -ExpectedSessionId' + } + if ($terminalSeen) { throw 'an event appears after terminal exited' } + + switch ($eventName) { + 'characterList' { + $null = Assert-String $root 'accountName' $true + $slotCount = Assert-Int32 $root 'slotCount' + if ($slotCount -lt 0) { throw 'slotCount is negative' } + $characters = $root.GetProperty('characters') + if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw 'characters is not an array' + } + foreach ($character in $characters.EnumerateArray()) { + if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'a character is not an object' + } + $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) + $characterExpected = @('id', 'name', 'secondsGreyedOut') + if ([string]::Join("`n", $characterNames) -cne + [string]::Join("`n", $characterExpected)) { + throw 'a character fields/order is not id,name,secondsGreyedOut' + } + $null = Assert-UInt32 $character 'id' + $null = Assert-String $character 'name' + $null = Assert-UInt32 $character 'secondsGreyedOut' + } + } + 'enteredWorld' { + $null = Assert-UInt32 $root 'characterId' + $null = Assert-String $root 'characterName' + } + 'pluginLoaded' { + $loadedPlugins.Add((Assert-String $root 'plugin')) + } + 'pluginFailed' { + $null = Assert-String $root 'plugin' + $null = Assert-String $root 'error' + if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } + } + 'loginCommandFailed' { + if ((Assert-Int32 $root 'commandIndex') -lt 0) { + throw 'commandIndex is negative' + } + $null = Assert-String $root 'command' $true + $null = Assert-String $root 'error' + if (-not $AllowLoginCommandFailure) { + throw 'loginCommandFailed is not allowed for this row' + } + } + 'disconnected' { $null = Assert-String $root 'reason' } + 'exited' { + $code = Assert-Int32 $root 'code' + $reason = Assert-String $root 'reason' + if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } + $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } + if ($reason -cne $expectedReason) { + throw "terminal reason is '$reason', expected '$expectedReason'" + } + $terminalSeen = $true + } + } + $eventNames.Add($eventName) + } + catch { + $failures.Add("line ${lineNumber}: $($_.Exception.Message)") + } + finally { if ($null -ne $document) { $document.Dispose() } } +} + +function Require-Count([string]$EventName, [int]$Count) { + $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count + if ($actual -ne $Count) { + $failures.Add("event '$EventName' count is $actual, expected $Count") + } +} +function First-Index([string]$EventName) { + for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq $EventName) { return $index } + } + return -1 +} + +Require-Count 'started' 1 +Require-Count 'connected' 1 +Require-Count 'characterList' 1 +Require-Count 'disconnected' 1 +Require-Count 'exited' 1 +$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld +Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) +if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { + $failures.Add('started is not the first event') +} +if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { + $failures.Add('exited is not the final event') +} +$orderedRequired = if (-not $expectEnteredWorld) { + @('started', 'connected', 'characterList', 'disconnected', 'exited') +} else { + @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') +} +$last = -1 +foreach ($name in $orderedRequired) { + $next = First-Index $name + if ($next -ge 0 -and $next -le $last) { + $failures.Add("event '$name' is out of lifecycle order") + } + $last = $next +} +$connectedIndex = First-Index 'connected' +foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { + if ($eventNames.Count -eq 0) { break } + if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and + ($index -le 0 -or $index -ge $connectedIndex)) { + $failures.Add("plugin event at index $index is outside started-to-connected startup") + } +} +foreach ($plugin in $ExpectedPlugin) { + if (-not ($loadedPlugins -ccontains $plugin)) { + $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") + } +} +$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) +$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) +if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { + $failures.Add('a plugin emitted pluginLoaded more than once') +} +if ([string]::Join("`n", $loadedPluginSet) -cne + [string]::Join("`n", $expectedPluginSet)) { + $failures.Add( + "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") +} +$enteredWorldIndex = First-Index 'enteredWorld' +for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq 'loginCommandFailed' -and + ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { + $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") + } +} + +if (-not $AllowLauncherChildren) { + $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) + do { + $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) + if ($children.Count -eq 0) { break } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($children.Count -gt 0) { + $failures.Add( + "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") + } +} + +$reportDirectory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($reportDirectory)) { + $null = New-Item -ItemType Directory -Force -Path $reportDirectory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-status-validation' + success = ($failures.Count -eq 0) + mode = $Mode + enteredWorldExpected = $expectEnteredWorld + statusFile = [IO.Path]::GetFileName($StatusFile) + statusSize = (Get-Item -LiteralPath $StatusFile).Length + statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() + lineCount = $lines.Count + eventNames = @($eventNames) + loadedPluginCount = $loadedPlugins.Count + terminalObserved = $terminalSeen + launcherChildrenAllowed = [bool]$AllowLauncherChildren + failures = @($failures) + validatedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA status validation report: $ReportPath" +if (-not $report.success) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +}