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"); + } }