Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Tools/windows/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (`<installed>\bin\zmx.exe ls`, then `zmx.exe kill <name>`) 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-<id>` 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 <thumbprint>` requires a trusted code-signing
Expand Down
123 changes: 119 additions & 4 deletions Tools/windows/PackageRuntime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 156 additions & 0 deletions Tools/windows/Tests/Packaging.Uninstall.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion Tools/windows/Tests/ValidationRunner.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions Tools/windows/validate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
1 change: 1 addition & 0 deletions Tools/windows/validation-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading