From 22878edebe113e835210729585c257198cae5f6f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 8 Oct 2026 14:34:16 -0700 Subject: [PATCH] Refuse Windows uninstall while GraphCode runs and make removal all-or-nothing Uninstall removed the scheduled task, Start-menu shortcut and PATH entry and then failed deleting the install root because live zmx session hosts held bin\zmx.exe, leaving a partial installation (Dev Box D10, 0.1.78-windows.beta17). Uninstall now refuses up front, changing nothing, while any process other than the managed daemon runs from the install root, naming each process and PID and how to end the sessions. After stopping the daemon it re-checks, probes every installed file for exclusive access, and renames the whole root aside in one step before removing integration; any failure moves it back, restores PATH and the shortcut, and restarts the daemon. User data preservation is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/PACKAGING.md | 30 ++++ Tools/windows/PackageRuntime.ps1 | 123 +++++++++++++- .../Tests/Packaging.Uninstall.Tests.ps1 | 156 ++++++++++++++++++ .../windows/Tests/ValidationRunner.Tests.ps1 | 2 +- Tools/windows/validate.ps1 | 1 + Tools/windows/validation-matrix.md | 1 + 6 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 Tools/windows/Tests/Packaging.Uninstall.Tests.ps1 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` |