From e2b1fb6450adb2899fdd289221e92cc29b4f01b3 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Tue, 20 Oct 2026 16:37:24 -0500 Subject: [PATCH] fix(installer): stop an upgrade early when an isolated Steam holds the helper files Windows cannot overwrite a DLL a running program has loaded. On 2026-10-20 an isolated Steam had MultiSeat.SteamHook.dll loaded, so the replace failed with "Access ... is denied" and rolled back, after the installer had already stopped the service and dropped the seats. The error never said to close Steam. Add a pre-flight that runs before the service is stopped. It probes every file under SteamIsolation\ for an exclusive open and lists the processes with an image or module there. On a block it prints which files and programs hold them, says the service was NOT stopped, explains how to close Steam, and exits 1 without touching the install folder. No SteamIsolation folder (first install) behaves as before. -Uninstall needs no check: it does not delete the folder. Co-Authored-By: Claude Sonnet 5.5 --- docs/steam-isolation.md | 14 ++ scripts/install-service.ps1 | 44 +++-- scripts/lib/install-lib.ps1 | 184 ++++++++++++++++++ .../Scripts/InstallLibTests.cs | 168 ++++++++++++++++ 4 files changed, 395 insertions(+), 15 deletions(-) diff --git a/docs/steam-isolation.md b/docs/steam-isolation.md index 62ec32b..fe1f722 100644 --- a/docs/steam-isolation.md +++ b/docs/steam-isolation.md @@ -96,3 +96,17 @@ The picker changes only the new game's environment. It does not inject a DLL int Manual user testing reported simultaneous Steam clients on the host and one Windows seat and successful Valheim launches via this helper. The game installation included a third-party Steamworks compatibility component; unmodified retail Steamworks games have not been validated. These reports supplement the fixture checks rather than establishing general game compatibility. The IPC override technique predates this implementation; see [steam-multibox-easysetup](https://github.com/dreamware-media/steam-multibox-easysetup). This implementation adds targeted hooks, startup readiness, restart propagation, and process-tree supervision. No claim is made that those additions outperform simpler launch scripts across all games. + +## Upgrading while Steam isolation is in use + +Windows will not overwrite a DLL that a running program has loaded. An isolated Steam loads `SteamIsolation\MultiSeat.SteamHook.dll`, and `MultiSeat.SteamLauncher.exe` runs from the same folder. So an upgrade cannot replace those files while that Steam is up. + +`scripts\install-service.ps1` checks for this before it stops the service. It tries to open every file under `SteamIsolation\` for exclusive write, and it lists the programs that have an image or module there. If anything is held, the installer prints what is using the files and exits with a non-zero code. The service is still running and the install folder is untouched. A first install, or a host with no `SteamIsolation\` folder, is not affected. + +To go on, close Steam in every seat that runs it, then run the installer again. Either exit Steam from its own menu, or run this as the Windows user that started it: + + MultiSeat.SteamLauncher.exe --steam "" --forward -- -shutdown + +A plain `steam -shutdown` does not reach an isolated Steam. The installer never closes Steam for you. + +`-Uninstall` does not need the check. It does not delete the install folder; it only removes the service and leaves the folder for you to delete. diff --git a/scripts/install-service.ps1 b/scripts/install-service.ps1 index 6630309..65d9501 100644 --- a/scripts/install-service.ps1 +++ b/scripts/install-service.ps1 @@ -705,24 +705,38 @@ try { # -- Stop service so its DLLs are not locked -------------------------- # Only now: the payload is verified and the host can run it. + # + # And only if no isolated Steam holds the helper files in SteamIsolation\. Windows cannot + # overwrite a loaded DLL, so the replace would fail after the service was already stopped + # and the seats dropped (measured 2026-10-20). Invoke-StopWhenNotBlocked probes the files and + # names the programs holding them first; when it reports a block, the stop below never runs. $svcBeforePublish = Get-Service $ServiceName -ErrorAction SilentlyContinue $serviceWasRunning = ($svcBeforePublish -and $svcBeforePublish.Status -eq 'Running') - if ($serviceWasRunning) { - Write-Step "Stopping service before the install..." - Stop-Service $ServiceName -Force - try { $svcBeforePublish.WaitForStatus('Stopped', (New-TimeSpan -Seconds 15)) } - catch { Write-Warning "SCM stop timed out -- will force-kill the process" } - } + $stopGate = Invoke-StopWhenNotBlocked -InstallDir $InstallDir -Stop { + if ($serviceWasRunning) { + Write-Step "Stopping service before the install..." + Stop-Service $ServiceName -Force + try { $svcBeforePublish.WaitForStatus('Stopped', (New-TimeSpan -Seconds 15)) } + catch { Write-Warning "SCM stop timed out -- will force-kill the process" } + } - # Kill any surviving process running the service exe by path - # (handles cases where the process outlives the SCM status change) - $serviceExe = Join-Path $InstallDir "MultiSeat.Service.exe" - $lingering = Get-Process -ErrorAction SilentlyContinue | - Where-Object { $_.Path -eq $serviceExe } - foreach ($proc in $lingering) { - Write-Step "Force-killing lingering process PID $($proc.Id)..." - $proc | Stop-Process -Force - $proc.WaitForExit(10000) + # Kill any surviving process running the service exe by path + # (handles cases where the process outlives the SCM status change) + $serviceExe = Join-Path $InstallDir "MultiSeat.Service.exe" + $lingering = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.Path -eq $serviceExe } + foreach ($proc in $lingering) { + Write-Step "Force-killing lingering process PID $($proc.Id)..." + $proc | Stop-Process -Force + $proc.WaitForExit(10000) + } + } + if ($stopGate.Blocked) { + Write-Host "" + Write-Host $stopGate.Message -ForegroundColor Yellow + Write-Host "" + # The finally block below still removes our staging folder. Nothing else was touched. + exit 1 } # Brief pause to let the OS release all file handles before the folder is replaced diff --git a/scripts/lib/install-lib.ps1 b/scripts/lib/install-lib.ps1 index a721815..0cdb8e1 100644 --- a/scripts/lib/install-lib.ps1 +++ b/scripts/lib/install-lib.ps1 @@ -495,3 +495,187 @@ function Install-Payload { return [pscustomobject]@{ ConfigBackup = $backupDir; FolderBackup = $folderBackup } } + +# -- Pre-flight: is an isolated Steam holding the helper files? --------------------------------- +# +# Measured 2026-10-20: an isolated Steam (steam.exe, its steamwebhelper children and +# MultiSeat.SteamLauncher.exe) had MultiSeat.SteamHook.dll loaded. Windows will not overwrite or +# delete a loaded DLL, so Install-Payload failed with "Access ... is denied" and rolled back. The +# rollback worked, but the installer had already stopped the service, which dropped every seat, +# and the error never said "close Steam". These functions find that case BEFORE the service is +# stopped. Only SteamIsolation\ is checked: the service's own files are held by the service, which +# the installer stops on purpose, so probing them would block every upgrade. + +function Test-FileHeld { + # $true when the file cannot be opened exclusively for write. That is the real failure (a + # loaded DLL or a running exe), measured rather than guessed from a process list. Only a + # sharing violation counts. A permissions error is not a lock and the installer can cope. + param([Parameter(Mandatory = $true)][string]$Path) + $fs = $null + try { + $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + return $false + } + catch [System.UnauthorizedAccessException] { return $false } + catch [System.IO.FileNotFoundException] { return $false } + catch [System.IO.DirectoryNotFoundException] { return $false } + catch [System.IO.IOException] { return $true } + finally { if ($fs) { $fs.Dispose() } } +} + +function Get-ProcessOwner { + param([Parameter(Mandatory = $true)][int]$ProcessId) + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop + $o = Invoke-CimMethod -InputObject $cim -MethodName GetOwner -ErrorAction Stop + if ($o -and $o.User) { return "$($o.Domain)\$($o.User)" } + } catch { } + return $null +} + +function Get-ProcessesUsingFolder { + <# + .SYNOPSIS + Running processes whose main image or any loaded module lives under Folder. + .DESCRIPTION + Protected processes and processes that exit while we look throw when their modules are + read. Those are skipped, never fatal. Returns objects with Name, Id, SessionId, Owner, + Path (the file that put it on the list) and Role 'holder'. + #> + param([Parameter(Mandatory = $true)][string]$Folder) + $prefix = [System.IO.Path]::GetFullPath($Folder).TrimEnd('\') + '\' + $found = @() + foreach ($p in @(Get-Process -ErrorAction SilentlyContinue)) { + $hit = $null + try { + $main = $p.MainModule + if ($main -and $main.FileName.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $hit = $main.FileName + } + } catch { } + if (-not $hit) { + try { + foreach ($m in @($p.Modules)) { + if ($m.FileName.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $hit = $m.FileName + break + } + } + } catch { } + } + if ($hit) { + $found += [pscustomobject]@{ + Name = $p.ProcessName; Id = $p.Id; SessionId = $p.SessionId + Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $hit; Role = 'holder' } + } + } + return $found +} + +function Test-SteamIsolationBlocksUpgrade { + <# + .SYNOPSIS + Would replacing InstallDir fail because an isolated Steam holds files in SteamIsolation\ ? + .DESCRIPTION + Two measurements, combined. Every file under SteamIsolation\ is probed for an exclusive + open (the real failure). Running processes with an image or module there are listed to + name the culprits. Blocked is true when either finds something. steam.exe and + steamwebhelper.exe in the same session as a culprit are listed too, with Role 'steam', + because they have to exit with it. A missing SteamIsolation\ folder (first install, or the + feature never installed) is clear. Reads only; changes nothing. + #> + param([Parameter(Mandatory = $true)][string]$InstallDir) + $dir = Join-Path $InstallDir 'SteamIsolation' + $result = [pscustomobject]@{ + Blocked = $false; Folder = $dir; LockedFiles = @(); Processes = @() } + if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $result } + + $locked = @() + foreach ($f in @(Get-ChildItem -LiteralPath $dir -Recurse -File -Force -ErrorAction SilentlyContinue)) { + if (Test-FileHeld -Path $f.FullName) { $locked += $f.FullName } + } + $procs = @(Get-ProcessesUsingFolder -Folder $dir) + + if ($procs.Count -gt 0) { + $sessions = @($procs | ForEach-Object { $_.SessionId }) + $known = @($procs | ForEach-Object { $_.Id }) + foreach ($p in @(Get-Process -Name 'steam', 'steamwebhelper' -ErrorAction SilentlyContinue)) { + if (($sessions -contains $p.SessionId) -and -not ($known -contains $p.Id)) { + $path = $null + try { $path = $p.MainModule.FileName } catch { } + $procs += [pscustomobject]@{ + Name = $p.ProcessName; Id = $p.Id; SessionId = $p.SessionId + Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $path; Role = 'steam' } + } + } + } + + $result.LockedFiles = @($locked) + $result.Processes = @($procs) + $result.Blocked = ($locked.Count -gt 0 -or $procs.Count -gt 0) + return $result +} + +function Format-SteamInUseMessage { + # Plain-language text for a blocked upgrade. Says the service was NOT stopped. + param( + [Parameter(Mandatory = $true)]$Check + ) + $launcher = Join-Path $Check.Folder 'MultiSeat.SteamLauncher.exe' + $steamExe = $null + foreach ($p in @($Check.Processes)) { + if ($p.Name -eq 'steam' -and $p.Path) { $steamExe = $p.Path; break } + } + if (-not $steamExe) { $steamExe = '' } + + $lines = @() + $lines += 'UPGRADE STOPPED EARLY: an isolated Steam is using the MultiSeat helper files.' + $lines += 'Windows will not overwrite a DLL that a running program has loaded.' + $lines += 'Nothing was changed. The MultiSeat service has NOT been stopped and your seats are still up.' + $lines += '' + if (@($Check.LockedFiles).Count -gt 0) { + $lines += 'Files that are in use:' + foreach ($f in @($Check.LockedFiles)) { $lines += " $f" } + $lines += '' + } + if (@($Check.Processes).Count -gt 0) { + $lines += 'Programs using them (or that must exit with them):' + foreach ($p in @($Check.Processes)) { + $who = if ($p.Owner) { $p.Owner } else { 'owner unknown' } + $note = if ($p.Role -eq 'steam') { ' (part of the same Steam)' } else { '' } + $lines += (' {0}.exe PID {1} session {2} {3}{4}' -f $p.Name, $p.Id, $p.SessionId, $who, $note) + } + $lines += '' + } + $lines += 'To fix this, close Steam in every seat that runs it. Then run this installer again.' + $lines += 'Either exit Steam from its own menu (Steam, then Exit),' + $lines += 'or run this as the Windows user that started that Steam:' + $lines += " `"$launcher`" --steam `"$steamExe`" --forward -- -shutdown" + $lines += 'A plain "steam -shutdown" does not reach an isolated Steam, so do not use it.' + $lines += 'This installer does not close Steam for you.' + return ($lines -join "`n") +} + +function Invoke-StopWhenNotBlocked { + <# + .SYNOPSIS + Run the service-stop step only if no isolated Steam holds the helper files. + .DESCRIPTION + The one place the pre-flight and the stop meet, so the order can be tested: when the + check says Blocked, the Stop scriptblock is NEVER called. Returns Blocked, Stopped, + Check and Message. The caller prints Message and exits non-zero on Blocked. + #> + param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][scriptblock]$Stop + ) + $check = Test-SteamIsolationBlocksUpgrade -InstallDir $InstallDir + if ($check.Blocked) { + return [pscustomobject]@{ + Blocked = $true; Stopped = $false; Check = $check + Message = (Format-SteamInUseMessage -Check $check) } + } + $null = & $Stop + return [pscustomobject]@{ Blocked = $false; Stopped = $true; Check = $check; Message = $null } +} diff --git a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs index 149342d..d372c8e 100644 --- a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs +++ b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs @@ -785,6 +785,159 @@ public void The_release_workflow_ships_all_four_helpers_in_the_asset() Assert.Contains("an upgrade would remove the Steam isolation helpers", yml); } + // -- Pre-flight: an isolated Steam holding the helper files (measured 2026-10-20) -------- + // + // Windows will not overwrite a loaded DLL. The upgrade used to stop the service first and + // discover that only when the replace failed. These tests hold REAL files open (and run a + // REAL process from SteamIsolation\) and check the answer, not just that a function exists. + + private static string HookDll(string root) => Path.Combine(root, "SteamIsolation", "MultiSeat.SteamHook.dll"); + + private static string CheckCall(string install) => + "$r = Test-SteamIsolationBlocksUpgrade -InstallDir " + Q(install) + "\n"; + + [Theory, MemberData(nameof(Engines))] + public void Preflight_is_clear_when_there_is_no_steam_isolation_folder(string engine) + { + using var t = new Temp(); // a first install: nothing there at all + var j = J(Ps(engine, CheckCall(t.Sub("ProgramFiles-MultiSeat")))); + Assert.False(j.GetProperty("Blocked").GetBoolean()); + Assert.Empty(Strings(j.GetProperty("LockedFiles"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Preflight_is_clear_when_nothing_holds_the_helper_files(string engine) + { + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + var j = J(Ps(engine, CheckCall(t.Root))); + Assert.False(j.GetProperty("Blocked").GetBoolean()); + Assert.Empty(Strings(j.GetProperty("LockedFiles"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Preflight_reports_a_held_file_by_name_and_clears_when_it_is_released(string engine) + { + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + JsonElement held; + using (new FileStream(HookDll(t.Root), FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + held = J(Ps(engine, CheckCall(t.Root))); + } + Assert.True(held.GetProperty("Blocked").GetBoolean()); + var locked = Strings(held.GetProperty("LockedFiles")); + Assert.Single(locked); + Assert.Equal(HookDll(t.Root), locked[0], ignoreCase: true); + + var released = J(Ps(engine, CheckCall(t.Root))); + Assert.False(released.GetProperty("Blocked").GetBoolean()); + Assert.Empty(Strings(released.GetProperty("LockedFiles"))); + } + + [Theory, MemberData(nameof(Engines))] + public void Preflight_names_a_running_process_whose_image_lives_in_steam_isolation(string engine) + { + // A real process started from SteamIsolation\, standing in for MultiSeat.SteamLauncher.exe. + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + var launcher = Path.Combine(t.Root, "SteamIsolation", "MultiSeat.SteamLauncher.exe"); + File.Copy(Path.Combine(Environment.SystemDirectory, "ping.exe"), launcher, overwrite: true); + using var proc = Process.Start(new ProcessStartInfo(launcher, "-n 120 127.0.0.1") + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true, + })!; + try + { + Thread.Sleep(500); + var j = J(Ps(engine, CheckCall(t.Root))); + Assert.True(j.GetProperty("Blocked").GetBoolean()); + var procs = j.GetProperty("Processes").EnumerateArray().ToArray(); + var mine = procs.Single(x => x.GetProperty("Id").GetInt32() == proc.Id); + Assert.Equal("holder", mine.GetProperty("Role").GetString()); + Assert.Equal(proc.SessionId, mine.GetProperty("SessionId").GetInt32()); + Assert.Equal(launcher, mine.GetProperty("Path").GetString(), ignoreCase: true); + // Its image is a locked file too, so the two measurements agree. + Assert.Contains(Strings(j.GetProperty("LockedFiles")), f => string.Equals(f, launcher, StringComparison.OrdinalIgnoreCase)); + } + finally + { + try { proc.Kill(); proc.WaitForExit(10_000); } catch (InvalidOperationException) { } + } + var after = J(Ps(engine, CheckCall(t.Root))); + Assert.False(after.GetProperty("Blocked").GetBoolean()); + Assert.Empty(after.GetProperty("Processes").EnumerateArray()); + } + + private static string GateCall(string install, string marker) => + "$r = Invoke-StopWhenNotBlocked -InstallDir " + Q(install) + + " -Stop { Set-Content -LiteralPath " + Q(marker) + " -Value stopped }\n"; + + [Theory, MemberData(nameof(Engines))] + public void A_blocked_preflight_never_runs_the_stop_step_and_says_how_to_close_steam(string engine) + { + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + var marker = t.Sub("stop-was-called.txt"); + JsonElement j; + using (new FileStream(HookDll(t.Root), FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + j = J(Ps(engine, GateCall(t.Root, marker))); + } + Assert.True(j.GetProperty("Blocked").GetBoolean()); + Assert.False(j.GetProperty("Stopped").GetBoolean()); + Assert.False(File.Exists(marker), "the stop step ran although the pre-flight reported a block"); + var msg = j.GetProperty("Message").GetString()!; + Assert.Contains("MultiSeat.SteamHook.dll", msg); + Assert.Contains("has NOT been stopped", msg); + Assert.Contains("--forward -- -shutdown", msg); + Assert.Contains("MultiSeat.SteamLauncher.exe", msg); + Assert.Contains("does not reach an isolated Steam", msg); + } + + [Theory, MemberData(nameof(Engines))] + public void A_clear_preflight_runs_the_stop_step_once(string engine) + { + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); // present but not held + var marker = t.Sub("stop-was-called.txt"); + var j = J(Ps(engine, GateCall(t.Root, marker))); + Assert.False(j.GetProperty("Blocked").GetBoolean()); + Assert.True(j.GetProperty("Stopped").GetBoolean()); + Assert.True(File.Exists(marker)); + } + + [Theory, MemberData(nameof(Engines))] + public void A_first_install_runs_the_stop_step_unchanged(string engine) + { + using var t = new Temp(); + var marker = t.Sub("stop-was-called.txt"); + var j = J(Ps(engine, GateCall(t.Sub("none-yet"), marker))); + Assert.False(j.GetProperty("Blocked").GetBoolean()); + Assert.True(File.Exists(marker)); + } + + [Theory, MemberData(nameof(Engines))] + public void Instrument_check_a_check_stubbed_to_always_clear_lets_the_stop_run_while_a_file_is_held(string engine) + { + // Proves the blocked-case assertions above can fail: with the check replaced by a stub that + // always says clear, the same held file no longer blocks and the marker IS written. + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + var marker = t.Sub("stop-was-called.txt"); + JsonElement j; + using (new FileStream(HookDll(t.Root), FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + j = J(Ps(engine, + "function Test-SteamIsolationBlocksUpgrade { param($InstallDir) " + + "[pscustomobject]@{ Blocked = $false; Folder = ''; LockedFiles = @(); Processes = @() } }\n" + + GateCall(t.Root, marker))); + } + Assert.False(j.GetProperty("Blocked").GetBoolean()); + Assert.True(File.Exists(marker)); + } + // -- Wiring of install-service.ps1 itself ---------------------------------------- private static string[] InstallerLines() => File.ReadAllLines(Path.Combine(RepoRoot, "scripts", "install-service.ps1")); @@ -822,4 +975,19 @@ public void The_installer_loads_the_library_and_both_paths_use_the_shared_replac // The old in-place wipe must not come back as a second, unshared copy. Assert.DoesNotContain("Get-ChildItem $InstallDir -Force | Remove-Item", text); } + + [Fact] + public void The_installer_runs_the_steam_preflight_before_it_can_stop_the_service() + { + var text = string.Join("\n", InstallerLines()); + var iStaging = text.IndexOf("$stamp = Get-Date", StringComparison.Ordinal); + var iGate = text.IndexOf("Invoke-StopWhenNotBlocked -InstallDir", iStaging, StringComparison.Ordinal); + var iStop = text.IndexOf("Stop-Service $ServiceName", iStaging, StringComparison.Ordinal); + var iBlocked = text.IndexOf("if ($stopGate.Blocked)", StringComparison.Ordinal); + var iExit = text.IndexOf("exit 1", iBlocked, StringComparison.Ordinal); + var iInstall = text.IndexOf("Install-Payload -Stage", StringComparison.Ordinal); + Assert.True(iGate > 0 && iGate < iStop, "the stop must sit inside the pre-flight gate, after it"); + Assert.True(iBlocked > iStop && iExit > iBlocked && iExit < iInstall, + "a block must exit non-zero before the install folder is replaced"); + } }