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
26 changes: 26 additions & 0 deletions Tools/windows/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,32 @@ native Windows file-sharing lock. It isolates daemon and shortcut operations;
the real-product packaging gate separately exercises scheduled-daemon
install/upgrade/rollback/uninstall.

## Successful update backup cleanup

After an upgrade commits, setup creates a sibling
`.GraphCode-rollback-<id>.cleanup-ready` sidecar before attempting to delete the
previous installation. The marker is created only for a committed transaction;
the unmarked `.GraphCode-rollback-<id>` directories described above remain
recovery-owned and must not be removed automatically.

An in-app update can leave the committed backup locked because the old
`graphcode-windows.exe` is still running from it. Normal installed-shell startup
scans only exact cleanup-ready sidecars beside `current`, takes an exclusive
lease on each marker, and confirms every remaining backup file can be opened
exclusively before deleting the directory. Multiple shell processes therefore
cannot own the same cleanup attempt. A lock, access error, race, or partial
deletion leaves the marker in place for a bounded background retry and a later
launch; cleanup never blocks startup or touches the active `current`
installation.

If setup cannot create the success marker, the upgrade remains successful and
warns. A backup that also cannot be deleted is deliberately left unmarked: this
may retain expendable files, but it cannot authorize deletion of failed-recovery
state. `Packaging.Rollback.Tests.ps1` covers the commit-only marker and its
secondary-failure behavior. `WindowsRollbackCleanup.zig` tests marked cleanup,
unmarked recovery preservation, locked-file retry, malformed markers, orphan
markers, and concurrent marker leases.

## Uninstall with GraphCode still running

Uninstall removes the installation completely or changes nothing. Before
Expand Down
35 changes: 34 additions & 1 deletion Tools/windows/PackageRuntime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,22 @@ function Move-InstallDirectory([string] $source, [string] $destination) {
$destination = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($destination)
[IO.Directory]::Move($source, $destination)
}
function Get-RollbackCleanupMarker([string] $backup) {
$backup = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($backup)
return "$backup.cleanup-ready"
}
function New-RollbackCleanupMarker([string] $backup) {
$marker = Get-RollbackCleanupMarker $backup
$stream = [IO.File]::Open($marker, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$bytes = [Text.Encoding]::UTF8.GetBytes("committed`n")
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
return $marker
}
function Install-Package([bool] $upgrade) {
try {
$root = Open-Package ($(if ($Package) { $Package } else { Fail "-Package is required" }))
Expand Down Expand Up @@ -455,7 +471,6 @@ function Install-Package([bool] $upgrade) {
throw $failure
} finally {
$cleanup = @($stage)
if ($committed) { $cleanup += $backup }
if (-not $keepShortcutBackup) { $cleanup += $shortcutBackup }
foreach ($path in $cleanup) {
if (Test-Path -LiteralPath $path) {
Expand All @@ -464,6 +479,24 @@ function Install-Package([bool] $upgrade) {
}
}
}
if ($committed -and $previousInstallMoved -and (Test-Path -LiteralPath $backup)) {
$cleanupMarker = Get-RollbackCleanupMarker $backup
try {
New-RollbackCleanupMarker $backup | Out-Null
} catch {
Write-Warning "GraphCode packaging: could not authorize later cleanup of committed backup '$backup': $($_.Exception.Message)"
}
try {
Remove-Item -LiteralPath $backup -Recurse -Force
} catch {
Write-Warning "GraphCode packaging: could not clean transaction directory '$backup': $($_.Exception.Message)"
}
if (-not (Test-Path -LiteralPath $backup) -and (Test-Path -LiteralPath $cleanupMarker)) {
try { Remove-Item -LiteralPath $cleanupMarker -Force } catch {
Write-Warning "GraphCode packaging: removed committed backup but could not clean transaction marker '$cleanupMarker': $($_.Exception.Message)"
}
}
}
}
Write-Output "Installed GraphCode $($metadata.version) at $InstallRoot"
} finally {
Expand Down
48 changes: 42 additions & 6 deletions Tools/windows/Tests/Packaging.Rollback.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ $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 ($name in @("Fail", "Require", "Copy-Tree", "Move-InstallDirectory", "Install-Package", "Open-Package", "Close-Package")) {
foreach ($name in @("Fail", "Require", "Copy-Tree", "Move-InstallDirectory", "Get-RollbackCleanupMarker",
"New-RollbackCleanupMarker", "Install-Package", "Open-Package", "Close-Package")) {
$definition = $ast.Find({
param($node)
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name
Expand All @@ -21,6 +22,7 @@ $copyTree = (Get-Command Copy-Tree).ScriptBlock
$moveDirectory = (Get-Command Move-InstallDirectory).ScriptBlock
$openPackage = (Get-Command Open-Package).ScriptBlock
$closePackage = (Get-Command Close-Package).ScriptBlock
$newRollbackCleanupMarker = (Get-Command New-RollbackCleanupMarker).ScriptBlock

function Assert([bool] $condition, [string] $message) {
if (-not $condition) { throw "RED: $message" }
Expand Down Expand Up @@ -86,6 +88,12 @@ function Move-InstallDirectory([string] $source, [string] $destination) {
}
& $moveDirectory $source $destination
}
function New-RollbackCleanupMarker([string] $backup) {
if ($case -eq "committed-cleanup-secondary") {
throw "injected cleanup marker failure"
}
& $newRollbackCleanupMarker $backup
}
function Remove-Item {
[CmdletBinding()]
param(
Expand All @@ -98,17 +106,24 @@ function Remove-Item {
if ($case -eq "remove-new" -and $targets -contains $InstallRoot) {
throw "injected promoted install removal failure"
}
if ($case -in @("committed-cleanup-blocked", "committed-cleanup-secondary") -and
@($targets | Where-Object { (Split-Path $_ -Leaf) -like ".GraphCode-rollback-*" }).Count) {
throw "injected committed cleanup lock"
}
Microsoft.PowerShell.Management\Remove-Item @PSBoundParameters
}

$versionWasProvided = $false
$NoScheduledTask = $false
$failures = [Collections.Generic.List[string]]::new()
$executed = 0
try {
foreach ($case in @("stop", "backup-move", "restore-move", "remove-new", "stop-new", "copy",
"staged-verification", "snapshot", "remove-task", "promote", "path", "shortcut",
"start", "restart", "restore-shortcut", "success", "fresh-success", "fresh-failure",
"portable-success", "locked-install", "relative-success")) {
"portable-success", "locked-install", "relative-success", "committed-cleanup-blocked",
"committed-cleanup-secondary")) {
$executed++
$lock = $null
$pushed = $false
try {
Expand All @@ -125,8 +140,10 @@ try {
}
$state = @{ stops = 0; starts = 0; closed = $false; shortcut = "old" }
$NoScheduledTask = $case -in @("portable-success", "locked-install")
$succeeds = $case -in @("success", "fresh-success", "portable-success", "relative-success")
$succeeds = $case -in @("success", "fresh-success", "portable-success", "relative-success",
"committed-cleanup-blocked", "committed-cleanup-secondary")
$errorMessage = $null
$installOutput = @()
$label = $case
if ($case -eq "fresh-failure") { $case = "start" }
if ($case -eq "locked-install") {
Expand All @@ -138,16 +155,31 @@ try {
$pushed = $true
$InstallRoot = ".\current"
}
try { Install-Package (-not $fresh) | Out-Null } catch { $errorMessage = $_.Exception.Message }
try { $installOutput = @(Install-Package (-not $fresh) 3>&1) } catch { $errorMessage = $_.Exception.Message }
Assert $state.closed "$label did not close the package"
Assert ($succeeds -eq (-not $errorMessage)) "$label returned an unexpected result: $errorMessage"
$backups = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter ".GraphCode-rollback-*")
$cleanupMarkers = @(Get-ChildItem -LiteralPath $caseRoot -File -Filter ".GraphCode-rollback-*.cleanup-ready")
$shortcuts = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter ".GraphCode-shortcut-*")
$stages = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter ".GraphCode-install-*")
Assert ($stages.Count -eq 0) "$label leaked its unpromoted stage"
if ($succeeds) {
Assert ((Get-Content (Join-Path $InstallRoot "bin\graphcoded.exe")) -eq "new") "$label did not install the new payload"
Assert ($backups.Count -eq 0 -and $shortcuts.Count -eq 0) "$label left transaction debris"
if ($label -eq "committed-cleanup-blocked") {
Assert ($backups.Count -eq 1) "$label did not retain the cleanup-blocked committed backup"
Assert ($cleanupMarkers.Count -eq 1) "$label did not mark the committed backup as cleanup-ready"
Assert ($cleanupMarkers[0].BaseName -eq $backups[0].Name) "$label marker did not identify its rollback backup"
Assert (($installOutput | Out-String) -match "could not clean transaction directory") "$label did not warn about blocked cleanup"
} elseif ($label -eq "committed-cleanup-secondary") {
Assert ($backups.Count -eq 1) "$label did not retain the cleanup-blocked committed backup"
Assert ($cleanupMarkers.Count -eq 0) "$label authorized cleanup after marker creation failed"
$warningText = $installOutput | Out-String
Assert ($warningText -match "could not authorize later cleanup" -and
$warningText -match "could not clean transaction directory") "$label did not report both cleanup failures"
} else {
Assert ($backups.Count -eq 0 -and $cleanupMarkers.Count -eq 0) "$label left transaction debris"
}
Assert ($shortcuts.Count -eq 0) "$label left shortcut transaction debris"
Assert ($state.shortcut -eq "new") "$label did not update the shortcut"
Assert ($NoScheduledTask -eq ($state.starts -eq 0 -and $state.stops -eq 0)) "$label ignored scheduled-task policy"
continue
Expand All @@ -161,13 +193,16 @@ try {
Assert ($backups.Count -eq 1) "$label deleted the only recoverable installation"
Assert ((Get-Content (Join-Path $backups[0].FullName "sentinel.txt")) -eq "previous installation") "$label corrupted the recovery payload"
Assert ($errorMessage.Contains($backups[0].FullName)) "$label did not report the retained backup path: $errorMessage"
Assert ($cleanupMarkers.Count -eq 0) "$label marked a required recovery backup as cleanup-ready"
Assert ($state.starts -eq $(if ($case -eq "stop-new") { 1 } else { 0 })) "$label started a daemon before the old payload was restored"
} elseif ($fresh) {
Assert (-not (Test-Path -LiteralPath $InstallRoot)) "$label left the failed fresh installation"
Assert ($cleanupMarkers.Count -eq 0) "$label marked a failed fresh install as cleanup-ready"
} else {
Assert (Test-Path -LiteralPath (Join-Path $InstallRoot "sentinel.txt")) "$label deleted the previous installation"
Assert ((Get-Content (Join-Path $InstallRoot "bin\graphcoded.exe")) -eq "old") "$label did not restore the old payload"
Assert ($backups.Count -eq 0) "$label left an unnecessary payload backup"
Assert ($cleanupMarkers.Count -eq 0) "$label marked a restored backup as cleanup-ready"
}
if ($case -in @("restart", "restore-shortcut", "restore-move", "remove-new", "stop-new")) {
$secondary = switch ($case) {
Expand Down Expand Up @@ -221,7 +256,8 @@ try {
}
}
if ($failures.Count) { throw ($failures -join "`n") }
Write-Output "Package transaction ownership and recoverable rollback contracts: PASS"
if ($executed -ne 23) { throw "Package rollback contract executed $executed of 23 cases" }
Write-Output "Package transaction ownership and recoverable rollback contracts (23 cases): PASS"
} finally {
if (Test-Path -LiteralPath $fixture) {
Microsoft.PowerShell.Management\Remove-Item -LiteralPath $fixture -Recurse -Force
Expand Down
15 changes: 15 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ foreach ($path in @(
"src\UpdateOfferPresentation.zig",
"src\WindowsOnboarding.zig",
"src\WindowsProductSettings.zig",
"src\WindowsRollbackCleanup.zig",
"src\Accessibility.zig",
"src\DesignTokens.zig",
"src\Wire.zig",
Expand Down Expand Up @@ -880,6 +881,20 @@ Invoke-Native "Windows update install executable tests" {
& $zig test src\WindowsUpdateInstall.zig -target x86_64-windows-msvc -lc -lwinhttp "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Committed rollback cleanup executable tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = if ($env:GRAPHCODE_WINGHOSTTY_ROOT) {
$env:GRAPHCODE_WINGHOSTTY_ROOT
} else {
Join-Path $depotRoot "Winghostty-worktrees\host-integration"
}
$include = Join-Path $winghosttyRoot "include"
Push-Location $shellRoot
try {
& $zig test src\WindowsRollbackCleanup.zig -target x86_64-windows-msvc `
-lc -lkernel32 "-I$include"
} finally { Pop-Location }
}
Invoke-Native "Frame buffer executable tests" {
Push-Location $shellRoot
try { & $zig test src\FrameBuffer.zig } finally { Pop-Location }
Expand Down
3 changes: 2 additions & 1 deletion Tools/windows/validation-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ The Windows port must have runnable commands before implementation fleets begin.
| Signed catalog integrity and publisher policy | `pwsh Tools\windows\Tests\Packaging.Signing.Tests.ps1` |
| 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` |
| Failed-upgrade preservation, committed-backup marking, 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` |
| Native accessibility provider (MSVC and Windows SDK) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.ps1` |
| Native accessibility harness failure attribution (mock tools, no native fixture) | `pwsh -NoProfile -File Tools\windows\Tests\AccessibilityProvider.Native.Tests.Harness.Tests.ps1` |
| Native release feed, URL handoff, and allocation safety | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes updater tests) |
| Successful-update rollback cleanup, locked retry, and failed-recovery preservation | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes `WindowsRollbackCleanup.zig`) |
| Real-product packaging and source/standalone install/upgrade/rollback/uninstall | `pwsh Tools\windows\validate.ps1 -Task packaging` |
| Shared Swift package | `swift test --package-path <shared-package>` once extracted |
| macOS app/daemon/CLI | `make test` |
Expand Down
Loading
Loading