feat(launcher): prepare Campaign LA11 user gate
This commit is contained in:
parent
09d84387a8
commit
f881e5b467
24 changed files with 3538 additions and 58 deletions
|
|
@ -28,6 +28,8 @@
|
|||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
571
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
571
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
|
|
@ -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.
|
||||
|
||||
- `<ABSOLUTE_REPOSITORY_ROOT>`: a clean Campaign LA worktree at the exact commit
|
||||
under test.
|
||||
- `<ABSOLUTE_RETAIL_DAT_DIRECTORY>`: a read-only source containing
|
||||
`client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
|
||||
`client_local_English.dat`.
|
||||
- `<ACE_PORT>`, `<LA11_SERVER>`, and `<LA11_ACCOUNT>`: 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.
|
||||
- `<OBSERVER_CHARACTER>`: a second user-controlled character that can observe a
|
||||
private `/tell` from each play mode.
|
||||
- `<DISPOSABLE_CHARACTER>`: 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('<ABSOLUTE_REPOSITORY_ROOT>')
|
||||
$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 '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
|
||||
```
|
||||
|
||||
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 `<SESSION_ID>`, then run:
|
||||
|
||||
```powershell
|
||||
$Status = Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl'
|
||||
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
||||
-StatusFile $Status `
|
||||
-Mode '<probe|guiSelect|gui|headless>' `
|
||||
-ExpectedSessionId '<SESSION_ID>' `
|
||||
-ReportPath (Join-Path $Evidence '<ROW>-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 `<ABSOLUTE_RETAIL_DAT_DIRECTORY>` 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 `<LA11_TEMP_SERVER>` at `127.0.0.1:<UNUSED_LOCAL_PORT>`, edit its name
|
||||
and port, then remove it. Confirm Cancel/Escape makes no mutation.
|
||||
2. Add `<LA11_SERVER>` at `127.0.0.1:<ACE_PORT>`.
|
||||
3. Under it add `<LA11_TEMP_ACCOUNT>` 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 `<LA11_ACCOUNT>` 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 `<LA11_ACCOUNT>`, 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 <OBSERVER_CHARACTER>, LA11-D-<UNIQUE_NONSECRET_NONCE>`. 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-<UNIQUE_NONSECRET_NONCE>`.
|
||||
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-<UNIQUE_NONSECRET_NONCE>`.
|
||||
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 `<DISPOSABLE_CHARACTER>`. 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/<SESSION_ID>/status.jsonl') `
|
||||
-Mode guiSelect `
|
||||
-ExpectNoEnteredWorld `
|
||||
-ExpectedSessionId '<SESSION_ID>' `
|
||||
-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('<ABSOLUTE_LINUX_REPOSITORY_PATH>')
|
||||
$FixtureLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_FIXTURE_PATH>')
|
||||
$PayloadsLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_PAYLOADS_PATH>')
|
||||
$LinuxGate = [IO.Path]::GetFullPath('<NEW_ABSOLUTE_LINUX_GATE_ROOT>')
|
||||
$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 `<ABSOLUTE_LINUX_RETAIL_DAT_DIRECTORY>`;
|
||||
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 <OBSERVER_CHARACTER>, LA11-I-<UNIQUE_NONSECRET_NONCE>`, 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-<timestamp>/
|
||||
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 `<DISPOSABLE_CHARACTER>` 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// <c>docs/ISSUES.md</c> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
|
|||
/// Deliberately carries no credential field — the password is a separate
|
||||
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
|
||||
/// 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
|
||||
/// <paramref name="SupportsConsoleGracefulStop"/> 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.
|
||||
/// </summary>
|
||||
public sealed record LauncherProcessSpec(
|
||||
string ExecutablePath,
|
||||
IReadOnlyList<string> Arguments,
|
||||
string? WorkingDirectory = null);
|
||||
string? WorkingDirectory = null,
|
||||
bool SupportsConsoleGracefulStop = true);
|
||||
|
|
|
|||
|
|
@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
|||
/// <summary>
|
||||
/// Requests a graceful stop — first
|
||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
|
||||
/// on Linux; a no-op on Windows today, see
|
||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
|
||||
/// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
|
||||
/// children),
|
||||
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
|
||||
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
|
||||
/// not exited within <paramref name="timeout"/>. A no-op if
|
||||
|
|
|
|||
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Windows launcher child created without a shell as a true console process-
|
||||
/// group leader. The native start is deliberately narrow: it exists only
|
||||
/// because <see cref="ProcessStartInfo"/> does not expose
|
||||
/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A process can be attached to only one console. Child creation and targeted
|
||||
/// control-event attachment therefore share one process-wide gate.
|
||||
/// </summary>
|
||||
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<StartupInfoEx>(),
|
||||
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<string> 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<SecurityAttributes>(),
|
||||
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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@
|
|||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
||||
reference. A per-RID launcher publish explicitly publishes the GL-free
|
||||
CLI as its own self-contained single file into the same directory. -->
|
||||
|
|
|
|||
248
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
248
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
internal enum LauncherStartupMode
|
||||
{
|
||||
Desktop,
|
||||
VerifyPublish,
|
||||
SelfUpdateHelper,
|
||||
SelfUpdateConfirmation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class LauncherStartupOptions
|
||||
{
|
||||
private readonly IReadOnlyList<string> _publicArguments;
|
||||
|
||||
private LauncherStartupOptions(
|
||||
LauncherStartupMode mode,
|
||||
ApplicationPathSet paths,
|
||||
Uri updateManifestUri,
|
||||
IReadOnlyList<string> publicArguments)
|
||||
{
|
||||
Mode = mode;
|
||||
Paths = paths;
|
||||
UpdateManifestUri = updateManifestUri;
|
||||
_publicArguments = Array.AsReadOnly(publicArguments.ToArray());
|
||||
}
|
||||
|
||||
internal LauncherStartupMode Mode { get; }
|
||||
|
||||
internal ApplicationPathSet Paths { get; }
|
||||
|
||||
internal Uri UpdateManifestUri { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<string> PublicArguments => _publicArguments;
|
||||
|
||||
internal static LauncherStartupOptions Parse(
|
||||
IReadOnlyList<string> arguments,
|
||||
Func<ApplicationPathSet>? 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<string> 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -25,5 +25,17 @@
|
|||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<!-- Build ordering only. The Windows CTRL_BREAK gate launches this
|
||||
console fixture in two independent native process groups. -->
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
<!-- Build ordering only. This WinExe fixture starts with no console and
|
||||
proves the production Avalonia-parent CTRL_BREAK path. -->
|
||||
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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<WindowsSystemChildProcess>(console);
|
||||
Assert.IsType<SystemChildProcess>(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<string> 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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
182
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
182
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
|
|
@ -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<LauncherStartupOptionsException>(() =>
|
||||
LauncherStartupOptions.Parse(
|
||||
["--verify-publish", "--cache-dir", Path.GetTempPath()]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidArguments))]
|
||||
public void RejectsInvalidPublicArguments(string[] arguments)
|
||||
{
|
||||
Assert.Throws<LauncherStartupOptionsException>(() =>
|
||||
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<string[]> InvalidArguments()
|
||||
{
|
||||
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
|
||||
var data = new TheoryData<string[]>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
333
tools/new-campaign-la-update-fixture.ps1
Normal file
333
tools/new-campaign-la-update-fixture.ps1
Normal file
|
|
@ -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"
|
||||
394
tools/run-campaign-la-preflight.ps1
Normal file
394
tools/run-campaign-la-preflight.ps1
Normal file
|
|
@ -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-<timestamp>
|
||||
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, '<redacted>', [StringComparison]::Ordinal)
|
||||
}
|
||||
}
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
||||
'$1$2<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||
'$1<redacted>$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 = '<internal contract check>'; 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 = '<internal contract check>'
|
||||
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 }
|
||||
}
|
||||
346
tools/test-campaign-la-session-status.ps1
Normal file
346
tools/test-campaign-la-session-status.ps1
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue