diff --git a/Tools/windows/PACKAGING.md b/Tools/windows/PACKAGING.md index dd85b4e4..b2948799 100644 --- a/Tools/windows/PACKAGING.md +++ b/Tools/windows/PACKAGING.md @@ -141,6 +141,9 @@ powershell.exe -NoProfile -File "$env:LOCALAPPDATA\GraphCode\current\GraphCode-S ``` Uninstall preserves user data by default; `-RemoveUserData` opts into removal. +Close GraphCode and end its terminal sessions first: Uninstall refuses, without +changing anything, while processes run from the installation (see +[Uninstall with GraphCode still running](#uninstall-with-graphcode-still-running)). Authenticate signed setup before uninstall too. Setup never changes PowerShell execution policy or certificate stores; organization policy may restrict execution. Do not disable that policy to bypass a signature failure. @@ -180,6 +183,33 @@ native Windows file-sharing lock. It isolates daemon and shortcut operations; the real-product packaging gate separately exercises scheduled-daemon install/upgrade/rollback/uninstall. +## Uninstall with GraphCode still running + +Uninstall removes the installation completely or changes nothing. Before +touching the daemon, scheduled task, PATH, or shortcut it lists every process +running from the installation root. Terminal session hosts (`zmx.exe`), the +shell, and the CLI are the user's live work, so Uninstall never kills them: it +refuses with exit code 1, names each process and PID, and explains how to end +the sessions (`\bin\zmx.exe ls`, then `zmx.exe kill `) and to +rerun Uninstall from a terminal outside GraphCode. Only the installed daemon is +stopped automatically, as before. + +After stopping the daemon, Uninstall re-checks for processes, confirms that +every installed file can be opened exclusively, and then renames the whole +installation root to a sibling `.GraphCode-uninstall-` directory in one +step. Any failure up to and including the PATH and shortcut removal moves the +installation back, restores PATH and the shortcut, and restarts the daemon if +it was running. Only after the integration is gone is the renamed directory +deleted; if a file is opened in that brief window, Uninstall still succeeds and +warns with the leftover directory to delete. User data is preserved by default +in every outcome. + +`Packaging.Uninstall.Tests.ps1` reproduces a live session host launched from +the installed `bin\zmx.exe`, a runtime DLL held open by another process, a +failed task removal after the rename, and a clean uninstall. It models the +scheduler and user PATH; the real-product packaging gate exercises the real +scheduled-daemon uninstall. + ## Signed package integrity Signing is opt-in: `-SignCertificate ` requires a trusted code-signing diff --git a/Tools/windows/PackageRuntime.ps1 b/Tools/windows/PackageRuntime.ps1 index eccb1eef..70e614a6 100644 --- a/Tools/windows/PackageRuntime.ps1 +++ b/Tools/windows/PackageRuntime.ps1 @@ -451,12 +451,127 @@ function Install-Package([bool] $upgrade) { Close-Package } } +function Get-FullInstallRoot { + $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallRoot).TrimEnd("\") +} +function Get-InstallRootProcesses { + $root = (Get-FullInstallRoot) + "\" + @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($root, [StringComparison]::OrdinalIgnoreCase) + }) +} +function Assert-InstallRootIdle([bool] $daemonManaged) { + # Session hosts (zmx), the shell, and the CLI are the user's work: refuse rather than kill them. + $daemon = Join-Path (Get-FullInstallRoot) "bin\graphcoded.exe" + $blocking = @(Get-InstallRootProcesses | Where-Object { + -not ($daemonManaged -and [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $daemon) + }) + if ($blocking.Count -eq 0) { return } + $summary = @($blocking | Group-Object Name | Sort-Object Name | ForEach-Object { + "$($_.Name) (PID $((@($_.Group | ForEach-Object { $_.ProcessId }) | Sort-Object) -join ', '))" + }) -join "; " + $zmx = Join-Path $InstallRoot "bin\zmx.exe" + Fail ("Uninstall changed nothing: $($blocking.Count) GraphCode process(es) are still running from " + + "$InstallRoot`: $summary. Close GraphCode, end its terminal sessions (list them with " + + "`"$zmx`" ls and stop each with `"$zmx`" kill NAME), then run Uninstall again from a " + + "terminal outside GraphCode.") +} +function Find-LockedInstallFile([string] $root) { + foreach ($file in @(Get-ChildItem -LiteralPath $root -File -Recurse -Force)) { + $access = if ($file.IsReadOnly) { [IO.FileAccess]::Read } else { [IO.FileAccess]::ReadWrite } + try { + # Exclusive opens fail for any open handle and, with write access, for mapped images. + $stream = [IO.File]::Open($file.FullName, [IO.FileMode]::Open, $access, [IO.FileShare]::None) + $stream.Dispose() + } catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + return "$($file.FullName) ($($failure.Message))" + } + } + return $null +} function Uninstall-Package { - if (-not $NoScheduledTask) { Stop-InstalledDaemon; Remove-DaemonTask } + $daemonManaged = -not $NoScheduledTask + $installed = Test-Path -LiteralPath $InstallRoot + if ($installed) { Assert-InstallRootIdle $daemonManaged } + $daemonWasRunning = $false + if ($daemonManaged) { + $daemonWasRunning = @(Get-InstalledDaemons).Count -gt 0 + Stop-InstalledDaemon + } $bin = Join-Path $InstallRoot "bin" - Set-UserPath $bin $false - Set-Shortcut $false - if (Test-Path $InstallRoot) { Remove-Item $InstallRoot -Recurse -Force } + $parent = Split-Path (Get-FullInstallRoot) -Parent + $removed = $null + $shortcutBackup = $null + $pathAttempted = $false + $shortcutAttempted = $false + $oldPath = $null + try { + if ($installed) { + # Re-check after stopping the daemon, then take the whole tree out of service with + # one rename so a held file can never leave a half-deleted installation behind. + Assert-InstallRootIdle $daemonManaged + $locked = Find-LockedInstallFile $InstallRoot + if ($locked) { + Fail "Uninstall changed nothing: an installed file is in use: $locked. Close the program using it, then run Uninstall again." + } + $removed = Join-Path $parent ".GraphCode-uninstall-$([guid]::NewGuid())" + Move-InstallDirectory $InstallRoot $removed + } + $shortcutBackup = Join-Path ([IO.Path]::GetTempPath()) "GraphCode-uninstall-shortcut-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path $shortcutBackup | Out-Null + Save-Shortcut $shortcutBackup + $oldPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($daemonManaged) { Remove-DaemonTask } + $pathAttempted = $true + Set-UserPath $bin $false + $shortcutAttempted = $true + Set-Shortcut $false + } catch { + $failure = $_ + $rollbackErrors = [Collections.Generic.List[string]]::new() + $restored = $true + if ($removed -and (Test-Path -LiteralPath $removed)) { + try { Move-InstallDirectory $removed $InstallRoot } catch { + $restored = $false + $rollbackErrors.Add("restoring the installation from '$removed': $($_.Exception.Message)") + } + } + if ($pathAttempted) { + try { [Environment]::SetEnvironmentVariable("Path", $oldPath, "User") } catch { + $rollbackErrors.Add("restoring user PATH: $($_.Exception.Message)") + } + } + $keepShortcutBackup = $false + if ($shortcutAttempted) { + try { Restore-Shortcut $shortcutBackup } catch { + $keepShortcutBackup = $true + $rollbackErrors.Add("restoring shortcuts from '$shortcutBackup': $($_.Exception.Message)") + } + } + if ($daemonWasRunning -and $restored) { + try { Start-DaemonTask } catch { + $rollbackErrors.Add("restarting the daemon: $($_.Exception.Message)") + } + } + if (-not $keepShortcutBackup -and $shortcutBackup -and (Test-Path -LiteralPath $shortcutBackup)) { + Remove-Item -LiteralPath $shortcutBackup -Recurse -Force -ErrorAction SilentlyContinue + } + if ($rollbackErrors.Count) { + Fail "Uninstall failed: $($failure.Exception.Message) Rollback incomplete: $($rollbackErrors -join '; ')." + } + throw $failure + } + if ($shortcutBackup -and (Test-Path -LiteralPath $shortcutBackup)) { + Remove-Item -LiteralPath $shortcutBackup -Recurse -Force -ErrorAction SilentlyContinue + } + if ($removed) { + try { Remove-Item -LiteralPath $removed -Recurse -Force } catch { + Write-Warning "GraphCode packaging: GraphCode was uninstalled, but leftover files remain at '$removed': $($_.Exception.Message) Delete that directory after closing the program using it." + } + } $data = Join-Path $env:USERPROFILE ".graphcode" if ($RemoveUserData -and -not $KeepUserData) { Remove-Item $data -Recurse -Force -ErrorAction SilentlyContinue diff --git a/Tools/windows/Tests/Packaging.Uninstall.Tests.ps1 b/Tools/windows/Tests/Packaging.Uninstall.Tests.ps1 new file mode 100644 index 00000000..63746aef --- /dev/null +++ b/Tools/windows/Tests/Packaging.Uninstall.Tests.ps1 @@ -0,0 +1,156 @@ +[CmdletBinding()] +param() + +# Uninstall must either remove the whole installation or change nothing. The live-session +# case reproduces the Dev Box D10 failure: a zmx session host launched from the installed +# bin keeps bin\zmx.exe locked while Uninstall runs. +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$fixture = Join-Path $repoRoot ".build\packaging-uninstall-$([guid]::NewGuid())" +$tokens = $null +$errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $repoRoot "Tools\windows\PackageRuntime.ps1"), [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw "Packaging script has parse errors: $errors" } +foreach ($definition in $ast.FindAll({ + param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $false)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +if (-not (Get-Command Uninstall-Package -CommandType Function -ErrorAction SilentlyContinue)) { + throw "Packaging helper is missing: Uninstall-Package" +} + +# The machine-wide surfaces (scheduler, registry PATH) are modelled; files, shortcuts and +# processes are real and live under the fixture. +function Get-InstalledDaemons { + if ($state.daemon) { @([pscustomobject]@{ ProcessId = 0 }) } else { @() } +} +function Stop-InstalledDaemon { $state.stops++; $state.daemon = $false } +function Test-DaemonTask([string] $name) { $state.task } +function Remove-DaemonTask { + if ($case -eq "task-removal-failure") { throw "injected task removal failure" } + $state.task = $false +} +function Start-DaemonTask { $state.starts++; $state.task = $true; $state.daemon = $true } +function Set-UserPath([string] $bin, [bool] $add) { $state.path = $add } + +function Assert([bool] $condition, [string] $message) { + if (-not $condition) { throw "$case`: $message" } +} +function Get-TreeDigest([string] $root) { + @(Get-ChildItem -LiteralPath $root -File -Recurse -Force | Sort-Object FullName | ForEach-Object { + "$($_.FullName.Substring($root.Length))=$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" + }) -join "`n" +} + +$oldProfile = $env:USERPROFILE +$oldAppData = $env:APPDATA +$NoScheduledTask = $false +$RemoveUserData = $false +$KeepUserData = $false +$failures = [Collections.Generic.List[string]]::new() +$executed = 0 +try { + foreach ($case in @("live-session", "locked-file", "task-removal-failure", "success")) { + $session = $null + $lock = $null + try { + $caseRoot = Join-Path $fixture $case + $InstallRoot = Join-Path $caseRoot "GraphCode\current" + $env:USERPROFILE = Join-Path $caseRoot "user" + $env:APPDATA = Join-Path $env:USERPROFILE "AppData\Roaming" + $bin = Join-Path $InstallRoot "bin" + New-Item -ItemType Directory -Force -Path $bin, (Join-Path $InstallRoot "licenses") | Out-Null + Copy-Item (Join-Path $env:SystemRoot "System32\PING.EXE") (Join-Path $bin "zmx.exe") + foreach ($name in @("graphcoded.exe", "graphcode.exe", "graphcode-windows.exe", "_FoundationICU.dll")) { + Set-Content (Join-Path $bin $name) "payload $name" + } + foreach ($name in @("GraphCode-Setup.ps1", "manifest.json", "metadata.json", "licenses\ZMX-LICENSE.txt")) { + Set-Content (Join-Path $InstallRoot $name) "payload $name" + } + $shortcut = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\GraphCode.lnk" + New-Item -ItemType Directory -Force -Path (Split-Path $shortcut -Parent) | Out-Null + Set-Content $shortcut "installed shortcut" + $userData = Join-Path $env:USERPROFILE ".graphcode\graph.json" + New-Item -ItemType Directory -Force -Path (Split-Path $userData -Parent) | Out-Null + Set-Content $userData '{"projects":["kept"]}' + $installBefore = Get-TreeDigest $InstallRoot + $dataBefore = Get-TreeDigest (Split-Path $userData -Parent) + $state = @{ daemon = $true; task = $true; path = $true; stops = 0; starts = 0 } + + if ($case -eq "live-session") { + $session = Start-Process -FilePath (Join-Path $bin "zmx.exe") -ArgumentList "-n", "120", "127.0.0.1" ` + -WindowStyle Hidden -PassThru + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not (Get-CimInstance Win32_Process -Filter "ProcessId=$($session.Id)" -ErrorAction SilentlyContinue) -and + [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 } + Assert (-not $session.HasExited) "the fixture session host did not start" + } + if ($case -eq "locked-file") { + # A runtime DLL held open by a process that is not itself launched from the install root. + $lock = [IO.File]::Open((Join-Path $bin "_FoundationICU.dll"), + [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + } + + $errorMessage = $null + $output = $null + try { $output = Uninstall-Package *>&1 | Out-String } catch { $errorMessage = $_.Exception.Message } + $debris = @(Get-ChildItem -LiteralPath (Split-Path $InstallRoot -Parent) -Force -Directory | + Where-Object { $_.Name -ne "current" }) + Assert ($debris.Count -eq 0) "left transaction debris: $($debris.Name -join ', ')" + Assert ((Get-TreeDigest (Split-Path $userData -Parent)) -ceq $dataBefore) "changed preserved user data" + + if ($case -eq "success") { + Assert (-not $errorMessage) "failed: $errorMessage" + Assert (-not (Test-Path -LiteralPath $InstallRoot)) "left the installation behind" + Assert (-not $state.task -and -not $state.daemon -and -not $state.path) "left daemon, task or PATH integration" + Assert (-not (Test-Path -LiteralPath $shortcut)) "left the Start-menu shortcut" + Assert ($output -match "User data preserved") "did not report preserved user data: $output" + $executed++ + continue + } + + Assert ([bool] $errorMessage) "succeeded although the installation could not be removed" + Assert ((Test-Path -LiteralPath $InstallRoot) -and (Get-TreeDigest $InstallRoot) -ceq $installBefore) ` + "left a partial installation: $errorMessage" + Assert ($state.task -and $state.path) "removed the task or PATH entry before failing: $errorMessage" + Assert ((Test-Path -LiteralPath $shortcut) -and (Get-Content $shortcut) -eq "installed shortcut") ` + "removed the Start-menu shortcut before failing: $errorMessage" + Assert $state.daemon "left the daemon stopped after refusing: $errorMessage" + switch ($case) { + "live-session" { + Assert ($errorMessage -match "changed nothing" -and $errorMessage -match "zmx\.exe" -and + $errorMessage -match "PID $($session.Id)\b" -and $errorMessage -match " kill ") ` + "did not name the running session host with an actionable fix: $errorMessage" + Assert ($state.stops -eq 0) "stopped the daemon before refusing up front" + Assert (-not $session.HasExited) "killed the user's live session host" + } + "locked-file" { + Assert ($errorMessage -match "changed nothing" -and $errorMessage -match "_FoundationICU\.dll") ` + "did not name the file in use: $errorMessage" + Assert ($state.starts -eq 1) "did not restart the daemon it stopped" + } + "task-removal-failure" { + Assert ($errorMessage -match "injected task removal failure") "lost the initiating failure: $errorMessage" + Assert ($state.starts -eq 1) "did not restart the daemon it stopped" + } + } + $executed++ + } catch { + $failures.Add("$_") + } finally { + if ($lock) { $lock.Dispose() } + if ($session -and -not $session.HasExited) { $session.Kill(); $session.WaitForExit() } + } + } + if ($failures.Count) { throw "RED: Uninstall lifecycle contract failed:`n$($failures -join "`n")" } + if ($executed -ne 4) { throw "Uninstall lifecycle contract executed $executed of 4 cases" } + Write-Output "Uninstall live-session refusal and all-or-nothing removal contracts (4 cases): PASS" +} finally { + $env:USERPROFILE = $oldProfile + $env:APPDATA = $oldAppData + if (Test-Path -LiteralPath $fixture) { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index b1bb6207..3e9127a2 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3051,7 +3051,7 @@ try { if ($runnerSource -notmatch '(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?Packaging\.Standalone\.Tests\.ps1.*?Packaging\.Tests\.ps1') { throw "RED: packaging validation does not run standalone setup contracts" } - foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1")) { + foreach ($contract in @("Packaging.ScriptSigning.Tests.ps1", "Packaging.Scheduler.Tests.ps1", "Packaging.Uninstall.Tests.ps1")) { if ($runnerSource -notmatch ('(?s)"packaging" \{\s*if \(\$PackagingPart -ne "real"\) \{\s*& .*?' + [regex]::Escape($contract) + '.*?Packaging\.Tests\.ps1')) { throw "RED: packaging validation does not run $contract" } diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index 5432272d..3bc5d31b 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -1299,6 +1299,7 @@ function Invoke-Task([string] $name) { & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.ScriptSigning.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Scheduler.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Rollback.Tests.ps1") + & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Uninstall.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Standalone.Tests.ps1") } if ($PackagingPart -ne "contracts") { diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 091f66bf..22270fc7 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -20,6 +20,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Native SignTool PS1 signature and tamper detection (SDK required) | `pwsh Tools\windows\Tests\Packaging.ScriptSigning.Tests.ps1` | | Native missing/idle task stop and deletion | `pwsh Tools\windows\Tests\Packaging.Scheduler.Tests.ps1` | | Failed-upgrade preservation and recoverable rollback | `pwsh Tools\windows\Tests\Packaging.Rollback.Tests.ps1` | +| Uninstall refusal with live sessions/locked files and all-or-nothing removal | `pwsh Tools\windows\Tests\Packaging.Uninstall.Tests.ps1` | | Standalone setup under PowerShell 5.1 and 7 | `pwsh Tools\windows\Tests\Packaging.Standalone.Tests.ps1` | | Release publishing: signing gates, asset labeling, workflow contract | `pwsh Tools\windows\Tests\Release.Tests.ps1` | | Product/investigation provider pin consistency | `pwsh Tools\windows\Tests\ProviderPins.Tests.ps1` |