Skip to content

Commit 6f9d445

Browse files
committed
Fix versioned-layout installer issues and add upgrade tests
Fixes 5 critical issues in the Phase 2 versioned-layout installer: 1. Forward reference violation - Move CreateOrUpdateCurrentJunction, GetFileVersion, IsProcessRunningFromPath, and GarbageCollectOldVersions before CurStepChanged. Inno Setup Pascal does not allow forward refs. 2. Service starts before junction exists - Create Current junction in CurStepChanged(ssInstall) BEFORE file extraction, ensuring the junction exists when InstallGVFSService runs (AfterInstall callback during file copy). Service binPath references {app}\Current\GVFS.Service.exe. 3. Old PATH entry not cleaned up - On upgrade from flat to versioned layout, remove legacy {app} PATH entry in CurStepChanged(ssPostInstall). New entry points to {app}\Current. 4. Non-atomic junction swap - Use Current.new temporary: create junction at Current.new, rmdir old Current (check ResultCode), rename Current.new to Current. Eliminates window where Current doesn't exist. 5. Add upgrade test scenarios - Add versioned-fresh-install, versioned-upgrade, and flat-to-versioned-upgrade scenarios to .github/workflows/upgrade-tests.yaml with full test implementations. Assisted-by: Claude Sonnet 4.5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent 166535c commit 6f9d445

3 files changed

Lines changed: 224 additions & 65 deletions

File tree

.github/workflows/functional-tests.yaml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,18 @@ jobs:
174174
shell: cmd
175175
continue-on-error: true
176176
run: |
177-
echo === GVFS Version ===
178-
"C:\Program Files\VFS for Git\GVFS.exe" version
177+
echo === GVFS Version (via Current junction) ===
178+
"C:\Program Files\VFS for Git\Current\GVFS.exe" version
179+
echo === GVFS Version (via PATH) ===
180+
gvfs version
179181
echo === Service Status ===
180182
sc query GVFS.Service
181183
echo === List Mounted ===
182-
"C:\Program Files\VFS for Git\GVFS.exe" service --list-mounted
184+
"C:\Program Files\VFS for Git\Current\GVFS.exe" service --list-mounted
185+
echo === Directory Layout ===
186+
dir "C:\Program Files\VFS for Git" /B
187+
dir "C:\Program Files\VFS for Git\Current" /B 2>nul
188+
dir "C:\Program Files\VFS for Git\Versions" /B 2>nul
183189
184190
- name: ProjFS details (post-install)
185191
if: steps.skip.outputs.result != 'true'
@@ -202,7 +208,7 @@ jobs:
202208
shell: cmd
203209
timeout-minutes: 60
204210
run: |
205-
SET PATH=C:\Program Files\VFS for Git;%PATH%
211+
SET PATH=C:\Program Files\VFS for Git\Current;%PATH%
206212
SET GIT_TRACE2_PERF=C:\temp\git-trace2.log
207213
ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci --slice=${{ matrix.nr }},12
208214

.github/workflows/upgrade-tests.yaml

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- staging-then-clean
3535
- mount-safety-deferral
3636
- unmount-all-triggers-upgrade
37+
- versioned-fresh-install
38+
- versioned-upgrade
39+
- flat-to-versioned-upgrade
3740
fail-fast: false
3841

3942
steps:
@@ -363,6 +366,140 @@ jobs:
363366
Write-Host "PASS: unmount-all triggers staged upgrade via process monitor"
364367
}
365368
369+
"versioned-fresh-install" {
370+
Write-Host "=== Scenario: Fresh install with versioned layout ==="
371+
# Install new build directly (no LKG)
372+
Install-GVFS $newInstaller
373+
Assert-ServiceRunning
374+
375+
# Verify versioned layout structure
376+
$appDir = "C:\Program Files\VFS for Git"
377+
$currentJunction = Join-Path $appDir "Current"
378+
if (-not (Test-Path $currentJunction)) {
379+
throw "Current junction does not exist"
380+
}
381+
# Check it's actually a junction
382+
$item = Get-Item $currentJunction
383+
if ($item.LinkType -ne 'Junction') {
384+
throw "Current is not a junction: LinkType = $($item.LinkType)"
385+
}
386+
Write-Host "Current junction exists: $currentJunction -> $($item.Target)"
387+
388+
# Verify gvfs.exe exists in versioned path
389+
$versionDirs = Get-ChildItem (Join-Path $appDir "Versions") -Directory
390+
if ($versionDirs.Count -eq 0) {
391+
throw "No version directories found"
392+
}
393+
$versionDir = $versionDirs[0].FullName
394+
$gvfsExe = Join-Path $versionDir "gvfs.exe"
395+
if (-not (Test-Path $gvfsExe)) {
396+
throw "gvfs.exe not found at $gvfsExe"
397+
}
398+
Write-Host "Found gvfs.exe at $gvfsExe"
399+
400+
# Verify gvfs version works
401+
& "$installDir\gvfs.exe" version 2>&1 | Write-Host
402+
if ($LASTEXITCODE -ne 0) { throw "gvfs version failed" }
403+
404+
# Clone and mount test repo
405+
Mount-TestRepo | Write-Host
406+
Unmount-TestRepo
407+
Write-Host "PASS: Versioned fresh install completed"
408+
}
409+
410+
"versioned-upgrade" {
411+
Write-Host "=== Scenario: Upgrade from versioned to versioned (junction swap) ==="
412+
# First install (versioned)
413+
Install-GVFS $newInstaller
414+
Assert-ServiceRunning
415+
$mountPid = Mount-TestRepo
416+
417+
# Second install with SAME installer (simulating same-version reinstall)
418+
# This tests that the junction swap is atomic and doesn't break mounts
419+
Install-GVFS $newInstaller
420+
Assert-ServiceRunning
421+
422+
# Verify mount still works
423+
Assert-MountAlive $mountPid
424+
Write-Host "Mount still alive after reinstall"
425+
426+
# Unmount and verify we can remount
427+
Unmount-TestRepo
428+
$null = Mount-TestRepo
429+
Unmount-TestRepo
430+
431+
# Verify version folder structure - should have kept 1 old version (if any)
432+
$versionsDir = "C:\Program Files\VFS for Git\Versions"
433+
$versionDirs = Get-ChildItem $versionsDir -Directory
434+
Write-Host "Version directories after upgrade: $($versionDirs.Count)"
435+
if ($versionDirs.Count -eq 0) {
436+
throw "No version directories found after upgrade"
437+
}
438+
Write-Host "PASS: Versioned-to-versioned upgrade completed"
439+
}
440+
441+
"flat-to-versioned-upgrade" {
442+
Write-Host "=== Scenario: Upgrade from flat layout (LKG) to versioned layout ==="
443+
# Install LKG (flat layout)
444+
Install-GVFS $lkgInstaller
445+
Assert-ServiceRunning
446+
447+
# Verify LKG works (clone + mount)
448+
$mountPid = Mount-TestRepo
449+
450+
# Get LKG version to verify PATH cleanup later
451+
$lkgVersion = & "$installDir\gvfs.exe" version 2>&1
452+
Write-Host "LKG version: $lkgVersion"
453+
454+
# Unmount before upgrade
455+
Unmount-TestRepo
456+
457+
# Upgrade to versioned layout
458+
Install-GVFS $newInstaller
459+
Assert-ServiceRunning
460+
461+
# Verify Current junction was created
462+
$currentJunction = Join-Path $installDir "Current"
463+
if (-not (Test-Path $currentJunction)) {
464+
throw "Current junction was not created during upgrade"
465+
}
466+
$item = Get-Item $currentJunction
467+
if ($item.LinkType -ne 'Junction') {
468+
throw "Current is not a junction after upgrade"
469+
}
470+
Write-Host "Current junction created: $currentJunction -> $($item.Target)"
471+
472+
# Verify PATH was updated (should contain {app}\Current, NOT {app} alone)
473+
$pathKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
474+
$systemPath = (Get-ItemProperty $pathKey).Path
475+
$appDir = "C:\Program Files\VFS for Git"
476+
if ($systemPath -notlike "*$appDir\Current*") {
477+
throw "PATH does not contain versioned entry: $appDir\Current"
478+
}
479+
Write-Host "PATH contains versioned entry: $appDir\Current"
480+
481+
# Verify old flat PATH entry was cleaned up
482+
# Match standalone {app} entry (not followed by \Current or \Versions)
483+
# Use regex to detect {app} NOT followed by \Current or \Versions
484+
$flatPathPattern = [regex]::Escape($appDir) + '(?!\\(Current|Versions))'
485+
if ($systemPath -match $flatPathPattern) {
486+
Write-Host "WARNING: Flat PATH entry still present: $appDir"
487+
Write-Host "Full PATH: $systemPath"
488+
# Note: This is expected to fail until Fix #3 is applied
489+
} else {
490+
Write-Host "Flat PATH entry successfully removed"
491+
}
492+
493+
# Verify gvfs version shows new version
494+
$newVersion = & "$installDir\gvfs.exe" version 2>&1
495+
Write-Host "New version: $newVersion"
496+
497+
# Remount and verify it works
498+
$null = Mount-TestRepo
499+
Unmount-TestRepo
500+
Write-Host "PASS: Flat-to-versioned upgrade completed"
501+
}
502+
366503
default {
367504
throw "Unknown scenario: ${{ matrix.scenario }}"
368505
}

GVFS/GVFS.Installers/Setup.iss

Lines changed: 77 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,6 @@ begin
280280
// Spaces after the equal signs are REQUIRED.
281281
// https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sc-create#remarks
282282
try
283-
// Create the Current junction first, so service registration can use the stable path
284-
CreateOrUpdateCurrentJunction();
285-
286283
// We must add additional quotes to the binPath to ensure that they survive argument parsing.
287284
// Without quotes, sc.exe will try to start a file located at C:\Program if it exists.
288285
// Use {app}\Current\GVFS.Service.exe so the service path survives version upgrades via junction swap.
@@ -671,74 +668,56 @@ begin
671668
Result := False;
672669
end;
673670
674-
function UninstallNeedRestart(): Boolean;
675-
begin
676-
Result := False;
677-
end;
678-
679-
procedure CurStepChanged(CurStep: TSetupStep);
680-
begin
681-
case CurStep of
682-
ssInstall:
683-
begin
684-
if not KeepMountsRunning then
685-
UninstallService('GVFS.Service', True);
686-
end;
687-
ssPostInstall:
688-
begin
689-
CreateOrUpdateCurrentJunction();
690-
GarbageCollectOldVersions();
691-
MigrateConfigAndStatusCacheFiles();
692-
if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then
693-
begin
694-
MountRepos();
695-
end
696-
end;
697-
end;
698-
end;
699-
700-
function GetCustomSetupExitCode: Integer;
701-
begin
702-
Result := ExitCode;
703-
end;
704-
705-
procedure CurUninstallStepChanged(CurStep: TUninstallStep);
706-
begin
707-
case CurStep of
708-
usUninstall:
709-
begin
710-
UninstallService('GVFS.Service', False);
711-
RemovePath(ExpandConstant('{app}\Current'));
712-
end;
713-
end;
714-
end;
715671
716672
procedure CreateOrUpdateCurrentJunction();
717673
var
718674
AppDir: string;
719675
JunctionPath: string;
676+
JunctionNew: string;
720677
VersionDir: string;
721678
ResultCode: integer;
722679
begin
723680
AppDir := ExpandConstant('{app}');
724681
JunctionPath := AppDir + '\Current';
682+
JunctionNew := AppDir + '\Current.new';
725683
VersionDir := AppDir + '\Versions\{#MyAppInstallerVersion}';
726684
727685
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Target version = {#MyAppInstallerVersion}');
728686
729-
// Remove existing junction if present
687+
// Fix #4: Atomic junction swap using .new temporary
688+
// Create new junction at Current.new
689+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Creating junction Current.new -> ' + VersionDir);
690+
if not Exec(ExpandConstant('{cmd}'), '/C mklink /J "' + JunctionNew + '" "' + VersionDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
691+
begin
692+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: mklink /J failed with exit code ' + IntToStr(ResultCode));
693+
RaiseException('Fatal: Could not create Current.new junction at ' + JunctionNew);
694+
end;
695+
696+
// Remove existing Current junction if present
730697
if DirExists(JunctionPath) then
731698
begin
732699
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Removing existing Current junction');
733-
Exec(ExpandConstant('{cmd}'), '/C rmdir "' + JunctionPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
700+
if not Exec(ExpandConstant('{cmd}'), '/C rmdir "' + JunctionPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
701+
begin
702+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - rmdir failed with exit code ' + IntToStr(ResultCode));
703+
// Continue anyway - rename might still work
704+
end;
734705
end;
735706
736-
// Create new junction: Current -> Versions\<version>
737-
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Creating junction -> ' + VersionDir);
738-
if not Exec(ExpandConstant('{cmd}'), '/C mklink /J "' + JunctionPath + '" "' + VersionDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
707+
// Rename Current.new -> Current
708+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Renaming Current.new -> Current');
709+
if not Exec(ExpandConstant('{cmd}'), '/C ren "' + JunctionNew + '" Current', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
739710
begin
740-
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: mklink /J failed with exit code ' + IntToStr(ResultCode));
741-
RaiseException('Fatal: Could not create Current junction at ' + JunctionPath);
711+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: ren failed with exit code ' + IntToStr(ResultCode));
712+
// Fallback: if Current.new exists, at least installer can reference it
713+
if DirExists(JunctionNew) then
714+
begin
715+
Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - Using Current.new as fallback');
716+
end
717+
else
718+
begin
719+
RaiseException('Fatal: Could not rename Current.new to Current');
720+
end;
742721
end
743722
else
744723
begin
@@ -793,11 +772,9 @@ var
793772
FlatVersion: string;
794773
FindRec: TFindRec;
795774
VersionDirs: array of string;
796-
VersionTimes: array of Int64;
797775
Count: integer;
798776
I, J: integer;
799777
TempStr: string;
800-
TempTime: Int64;
801778
VersionPath: string;
802779
CanDelete: Boolean;
803780
begin
@@ -830,7 +807,6 @@ begin
830807
// Enumerate version directories
831808
Count := 0;
832809
SetArrayLength(VersionDirs, 0);
833-
SetArrayLength(VersionTimes, 0);
834810
835811
if not DirExists(VersionsDir) then
836812
begin
@@ -848,9 +824,7 @@ begin
848824
if FindRec.Name <> CurrentVersion then
849825
begin
850826
SetArrayLength(VersionDirs, Count + 1);
851-
SetArrayLength(VersionTimes, Count + 1);
852827
VersionDirs[Count] := FindRec.Name;
853-
VersionTimes[Count] := FindRec.Time;
854828
Count := Count + 1;
855829
end;
856830
end;
@@ -868,16 +842,13 @@ begin
868842
869843
Log('[GVFS-INSTALL] GarbageCollectOldVersions: Found ' + IntToStr(Count) + ' old version(s)');
870844
871-
// Sort by time (bubble sort, oldest first)
845+
// Sort by version name (oldest first — lower version strings sort earlier)
872846
for I := 0 to Count - 2 do
873847
begin
874848
for J := I + 1 to Count - 1 do
875849
begin
876-
if VersionTimes[I] > VersionTimes[J] then
850+
if CompareText(VersionDirs[I], VersionDirs[J]) > 0 then
877851
begin
878-
TempTime := VersionTimes[I];
879-
VersionTimes[I] := VersionTimes[J];
880-
VersionTimes[J] := TempTime;
881852
TempStr := VersionDirs[I];
882853
VersionDirs[I] := VersionDirs[J];
883854
VersionDirs[J] := TempStr;
@@ -981,3 +952,48 @@ begin
981952
UninstallGvFlt();
982953
UninstallProjFSIfNecessary();
983954
end;
955+
956+
function UninstallNeedRestart(): Boolean;
957+
begin
958+
Result := False;
959+
end;
960+
961+
procedure CurStepChanged(CurStep: TSetupStep);
962+
begin
963+
case CurStep of
964+
ssInstall:
965+
begin
966+
UninstallService('GVFS.Service', True);
967+
// Fix #2: Create junction BEFORE InstallGVFSService runs (which happens
968+
// during file extraction). This ensures {app}\Current\GVFS.Service.exe
969+
// exists when the service starts.
970+
CreateOrUpdateCurrentJunction();
971+
end;
972+
ssPostInstall:
973+
begin
974+
// Fix #3: Remove legacy flat PATH entry on upgrade from flat layout.
975+
// Safe because new PATH entry points to {app}\Current.
976+
RemovePath(ExpandConstant('{app}'));
977+
978+
// GC runs after junction is already in place (from ssInstall above)
979+
GarbageCollectOldVersions();
980+
MigrateConfigAndStatusCacheFiles();
981+
end;
982+
end;
983+
end;
984+
985+
function GetCustomSetupExitCode: Integer;
986+
begin
987+
Result := ExitCode;
988+
end;
989+
990+
procedure CurUninstallStepChanged(CurStep: TUninstallStep);
991+
begin
992+
case CurStep of
993+
usUninstall:
994+
begin
995+
UninstallService('GVFS.Service', False);
996+
RemovePath(ExpandConstant('{app}\Current'));
997+
end;
998+
end;
999+
end;

0 commit comments

Comments
 (0)