diff --git a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs index ec41fa1..6606d45 100644 --- a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs @@ -230,6 +230,50 @@ namespace TeslaConsole.DiffTests Assert.Empty(mClient.GetLaunchedApps()); } + [Fact] + public void RealLaunch_Watchdog_Restarts_AutoRestart_Apps_That_Exit_On_Their_Own() + { + mLauncher.RealLaunch = true; + mLauncher.RealAutoRestart = true; + var app = new LaunchData + { + LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Short Pinger" }, + WorkingDirectory = Environment.SystemDirectory, + ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"), + Arguments = "-n 3 127.0.0.1", // exits on its own after ~2 s + AutoRestart = true + }; + mClient.InstallApp(app); + int firstPid = mClient.LaunchApp(app.LaunchPair.LaunchKey); + Assert.True(firstPid > 0); + + // The app exits by itself; the watchdog must bring up a NEW pid + // (exit ~2 s + the Agent's 2 s restart delay). + int restartedPid = 0; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (DateTime.UtcNow < deadline) + { + var launched = mClient.GetLaunchedApps(); + if (launched.Length == 1 && launched[0].ProcessId != firstPid) + { + restartedPid = launched[0].ProcessId; + break; + } + Thread.Sleep(100); + } + Assert.True(restartedPid > 0, "watchdog did not restart the exited app"); + + // Turning the watchdog off ends the cycle: the current instance + // exits on its own and nothing relaunches it. + mLauncher.RealAutoRestart = false; + deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + while (DateTime.UtcNow < deadline && mClient.GetLaunchedApps().Length > 0) + { + Thread.Sleep(200); + } + Assert.Empty(mClient.GetLaunchedApps()); + } + [Fact] public void RealLaunch_Missing_Exe_Surfaces_The_Agents_Error() { diff --git a/vPOD/LauncherRpcServer.cs b/vPOD/LauncherRpcServer.cs index de32eda..116a4ae 100644 --- a/vPOD/LauncherRpcServer.cs +++ b/vPOD/LauncherRpcServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net; @@ -28,8 +29,9 @@ namespace VPod; /// - Install completion reports 99% (not 100) — the console's /// InstallProductWorker breaks its retry loop only on 99. /// -/// All state lives in ; unlike the real service, -/// postinstall.bat from a package is logged but never executed. +/// All state lives in ; a packaged postinstall.bat +/// is logged and removed unrun unless +/// is set from the vPOD window, in which case it is executed like the real service. /// internal sealed class LauncherRpcServer { @@ -362,12 +364,48 @@ internal sealed class LauncherRpcServer Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}"); // The real service runs (then deletes) a packaged postinstall.bat here. - // vPOD never executes package scripts on the host machine. + // vPOD only does so when the operator opts in via RunPostInstall; + // otherwise the script is logged and removed unrun (default), since it + // runs package code on the host machine. string postInstall = Path.Combine(gamesRoot, "postinstall.bat"); if (File.Exists(postInstall)) { - mLauncher.UpdateProgress(callId, 96, "Skipping postinstall (vPOD)..."); - Log?.Invoke($"Install {callId:N}: postinstall.bat present — NOT executed (vPOD), removed."); + if (mLauncher.RunPostInstall) + { + mLauncher.UpdateProgress(callId, 96, "Running postinstall..."); + Log?.Invoke($"Install {callId:N}: running postinstall.bat..."); + try + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/c \"" + postInstall + "\"", + WorkingDirectory = gamesRoot, + UseShellExecute = false, + CreateNoWindow = true + }; + using (Process proc = Process.Start(psi)) + { + if (proc.WaitForExit(300000)) + { + Log?.Invoke($"Install {callId:N}: postinstall.bat exited with code {proc.ExitCode}."); + } + else + { + Log?.Invoke($"Install {callId:N}: postinstall.bat still running after 5 min — leaving it, continuing."); + } + } + } + catch (Exception ex) + { + Log?.Invoke($"Install {callId:N}: postinstall.bat failed to run: {ex.Message}"); + } + } + else + { + mLauncher.UpdateProgress(callId, 96, "Skipping postinstall (vPOD)..."); + Log?.Invoke($"Install {callId:N}: postinstall.bat present — NOT executed (vPOD), removed."); + } try { File.Delete(postInstall); } catch { } } diff --git a/vPOD/README.md b/vPOD/README.md index f240ebb..2771656 100644 --- a/vPOD/README.md +++ b/vPOD/README.md @@ -110,17 +110,24 @@ launch entries point — and reports the launcher's usual `0–50%` receive / admin-owned `C:\Games` isn't writable by your account, the install reports Failed — fix the folder's ACL or run vPOD elevated). Uninstalling a product removes its `C:\Games\` folder, like the real launcher. A packaged - `postinstall.bat` is logged and removed but **never executed**. + `postinstall.bat` is logged and removed **unrun by default**; the **"Run + postinstall.bat after install"** checkbox makes the install execute it (via + `cmd /c`, waited on up to 5 min) before deleting it, like the real Agent — + off by default because it runs package script code on the host machine. - **Launch/Kill** from the squad panel simulate PIDs by default. The **"Actually launch apps (real processes)"** checkbox switches to the real Agent's behavior: LaunchApp starts the entry's exe from `C:\Games` (missing exe → the same "registered but not yet installed" error a real pod gives), Kill\* terminate the processes, and apps that exit or crash on their own - disappear from the console's running list. Real processes also die when the - pod is powered off / rebooted / the vPOD window closes. Caveat: launching a - *deployed vPOD* or a real game client this way will fight the running vPOD - for ports 1501/53290. Volume round-trips; **Restart/Shutdown** power-cycle - the virtual pod (dark for a few seconds on restart). + disappear from the console's running list. The indented **"Auto-restart + after the app exits (watchdog)"** checkbox (on by default) adds the Agent's + watchdog: an `autoRestart` entry that exits on its own relaunches after 2 s, + while console-ordered kills stay down — exactly the real pod's behavior. + Real processes also die when the pod is powered off / rebooted / the vPOD + window closes (which also cancels pending watchdog restarts). Caveat: + launching a *deployed vPOD* or a real game client this way will fight the + running vPOD for ports 1501/53290. Volume round-trips; **Restart/Shutdown** + power-cycle the virtual pod (dark for a few seconds on restart). ### Ports @@ -138,9 +145,7 @@ Same-machine testing needs no firewall changes (loopback). Running vPOD on a Not emulated: the console's remote Windows-service control (`ServiceController` over SCM/SMB, used by some SitePanel service start/stop paths — dormant against real pods too, since it queries service name `TeslaLauncherService` while the -launcher registers as `Tesla Application Launcher`), and the Agent's -`autoRestart` watchdog (a real-launched app that dies is pruned from the -running list, not relaunched). +launcher registers as `Tesla Application Launcher`). An end-to-end loopback test of this server against the console's real `PodManagerConnection` client lives in the differential suite: diff --git a/vPOD/VPodForm.cs b/vPOD/VPodForm.cs index 308b1bc..802c2bd 100644 --- a/vPOD/VPodForm.cs +++ b/vPOD/VPodForm.cs @@ -55,7 +55,9 @@ internal sealed class VPodForm : Form private Label mInstallStatusLabel; private ProgressBar mInstallProgressBar; private Button mReprovisionButton; + private CheckBox mRunPostInstallCheckbox; private CheckBox mRealLaunchCheckbox; + private CheckBox mRealAutoRestartCheckbox; private ListView mAppsView; private bool mPoweredOn; @@ -281,7 +283,7 @@ internal sealed class VPodForm : Form Padding = new Padding(8) }; - Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 234 }; + Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 278 }; mProvisionStatusLabel = new Label { @@ -352,24 +354,60 @@ internal sealed class VPodForm : Form }; mReprovisionButton.Click += ReprovisionClicked; + // Off (default) = a packaged postinstall.bat is logged and removed unrun; + // on = it is executed at the end of an install, like the real Agent. + mRunPostInstallCheckbox = new CheckBox + { + Text = "Run postinstall.bat after install", + Location = new Point(4, 206), + AutoSize = true, + Checked = false + }; + mRunPostInstallCheckbox.CheckedChanged += (s, e) => + { + mLauncher.RunPostInstall = mRunPostInstallCheckbox.Checked; + OnSiteLog(mRunPostInstallCheckbox.Checked + ? "Installs now EXECUTE a packaged postinstall.bat." + : "Installs skip (and remove) a packaged postinstall.bat."); + }; + // Off = LaunchApp records simulated PIDs; on = start/kill real processes // (the entries point into the real C:\Games, so this runs what the // console deployed — exactly like the Agent). mRealLaunchCheckbox = new CheckBox { Text = "Actually launch apps (real processes)", - Location = new Point(4, 206), + Location = new Point(4, 228), AutoSize = true, Checked = false }; mRealLaunchCheckbox.CheckedChanged += (s, e) => { mLauncher.RealLaunch = mRealLaunchCheckbox.Checked; + mRealAutoRestartCheckbox.Enabled = mRealLaunchCheckbox.Checked; OnSiteLog(mRealLaunchCheckbox.Checked ? "Launch/Kill now start and terminate REAL processes." : "Launch/Kill now simulate PIDs (no real processes)."); }; + // The Agent's autoRestart watchdog for real-launched apps (applies only + // to entries registered with autoRestart, like the real pod). + mRealAutoRestartCheckbox = new CheckBox + { + Text = "Auto-restart after the app exits (watchdog)", + Location = new Point(22, 250), + AutoSize = true, + Checked = true, + Enabled = false // meaningful only in real-launch mode + }; + mRealAutoRestartCheckbox.CheckedChanged += (s, e) => + { + mLauncher.RealAutoRestart = mRealAutoRestartCheckbox.Checked; + OnSiteLog(mRealAutoRestartCheckbox.Checked + ? "Watchdog ON: autoRestart apps relaunch 2 s after exiting." + : "Watchdog OFF: exited apps stay down."); + }; + siteTop.Controls.Add(mProvisionStatusLabel); siteTop.Controls.Add(mPassphrasePanel); siteTop.Controls.Add(mNetConfigLabel); @@ -377,7 +415,9 @@ internal sealed class VPodForm : Form siteTop.Controls.Add(mInstallStatusLabel); siteTop.Controls.Add(mInstallProgressBar); siteTop.Controls.Add(mReprovisionButton); + siteTop.Controls.Add(mRunPostInstallCheckbox); siteTop.Controls.Add(mRealLaunchCheckbox); + siteTop.Controls.Add(mRealAutoRestartCheckbox); mAppsView = new ListView { @@ -416,7 +456,7 @@ internal sealed class VPodForm : Form { mServer.Stop(); StopManagement(); - mLauncher.KillAllApps(); // don't orphan real launched processes when the tool exits + mLauncher.KillAllApps(cancelPendingRestarts: true); // don't orphan real launched processes when the tool exits } /// @@ -443,7 +483,7 @@ internal sealed class VPodForm : Form mRebootTimer.Stop(); StopGame(); StopManagement(); - mLauncher.KillAllApps(); // the machine is "off": launched apps (real or simulated) die with it + mLauncher.KillAllApps(cancelPendingRestarts: true); // the machine is "off": launched apps (real or simulated) die with it SetPoweredState(on: false); } @@ -869,7 +909,7 @@ internal sealed class VPodForm : Form mRebootTimer.Stop(); StopGame(); StopManagement(); - mLauncher.KillAllApps(); // launched apps die with the rebooting/halting machine + mLauncher.KillAllApps(cancelPendingRestarts: true); // launched apps die with the rebooting/halting machine SetPoweredState(on: false); if (restart) { diff --git a/vPOD/VirtualLauncher.cs b/vPOD/VirtualLauncher.cs index 9af9528..0efa38d 100644 --- a/vPOD/VirtualLauncher.cs +++ b/vPOD/VirtualLauncher.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text.Json; +using System.Threading; using Tesla.Net; namespace VPod; @@ -44,6 +45,23 @@ internal sealed class VirtualLauncher /// Entries launched in either mode coexist — kills handle both. public bool RealLaunch { get; set; } + /// The Agent's autoRestart watchdog for real-launched apps: when a + /// tracked process exits on its own (not via a Kill* command), relaunch it + /// after 2 s — but only for entries whose LaunchData.AutoRestart is set, + /// exactly like the real pod. Toggled from the vPOD window. + public bool RealAutoRestart { get; set; } = true; + + /// When set, a packaged postinstall.bat is executed at the end of a + /// product install (like the real Agent) before being deleted, instead of + /// being logged and removed unrun. Toggled from the vPOD window; off by + /// default, since it runs package script code on the host machine. + public bool RunPostInstall { get; set; } + + // Bumped to cancel watchdog restarts pending in their 2 s delay — the pod + // "machine" went dark (power off / reboot / reprovision), so nothing may + // relaunch after it. + private int mWatchdogGeneration; + public event Action Log; public event Action AppsChanged; // installed or launched list changed public event Action InstallProgressChanged; @@ -144,13 +162,11 @@ internal sealed class VirtualLauncher public LaunchedAppData[] GetLaunchedApps() { - PruneExitedProcesses(); lock (mLock) { return mLaunchedApps.ToArray(); } } public FullUpdateData FullUpdate() { - PruneExitedProcesses(); lock (mLock) { return new FullUpdateData @@ -304,11 +320,79 @@ internal sealed class VirtualLauncher mLaunchedApps.Add(new LaunchedAppData { ProcessId = process.Id, LaunchKey = app.LaunchPair.LaunchKey }); mRealProcesses[process.Id] = process; } + StartWatcher(app, process, process.Id); Log?.Invoke($"LaunchApp: \"{name}\" -> started {app.ExeFile} (real PID {process.Id})."); AppsChanged?.Invoke(); return process.Id; } + /// The Agent's per-process watcher: waits for exit, and if the process + /// is STILL TRACKED (Kill*/Uninstall remove it from the table first, so a + /// console-ordered kill never restarts), drops it from the running list and — + /// when the watchdog toggle and the entry's AutoRestart both apply — relaunches + /// it after the Agent's 2 s delay. + private void StartWatcher(LaunchData app, Process process, int pid) + { + string name = app.LaunchPair.DisplayName ?? app.LaunchPair.LaunchKey.ToString(); + new Thread(() => + { + try { process.WaitForExit(); } + catch { /* handle disposed by a Kill* — it is no longer tracked */ } + + bool stillTracked; + lock (mLock) + { + stillTracked = mRealProcesses.TryGetValue(pid, out Process tracked) && ReferenceEquals(tracked, process); + if (stillTracked) + { + mRealProcesses.Remove(pid); + mLaunchedApps.RemoveAll(l => l.ProcessId == pid); + process.Dispose(); + } + } + if (!stillTracked) + { + return; // killed via the console / power-off; whoever killed it cleaned up + } + AppsChanged?.Invoke(); + + int generation = Volatile.Read(ref mWatchdogGeneration); + if (!RealAutoRestart || !app.AutoRestart) + { + Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own (no watchdog restart)."); + return; + } + Log?.Invoke($"\"{name}\" (PID {pid}) exited on its own — watchdog restarting in 2 s..."); + Thread.Sleep(2000); + + // Still wanted? The pod may have powered off/reprovisioned (generation), + // the mode or toggle flipped, or the app been uninstalled meanwhile. + if (generation != Volatile.Read(ref mWatchdogGeneration) || !RealLaunch || !RealAutoRestart) + { + return; + } + LaunchData current; + lock (mLock) + { + int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == app.LaunchPair.LaunchKey); + if (index < 0) + { + return; // uninstalled during the delay + } + current = mInstalledApps[index]; + } + try + { + LaunchRealProcess(current); + } + catch (Exception ex) + { + Log?.Invoke($"Watchdog restart of \"{name}\" failed: {ex.Message}"); + } + }) + { IsBackground = true, Name = $"vPOD-watchdog-{pid}" }.Start(); + } + public void KillApp(Guid launchKey, int processId) { List victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId); @@ -331,8 +415,16 @@ internal sealed class VirtualLauncher } } - public void KillAllApps() + /// Kills everything launched. + /// is the pod-power path (off/reboot/exit): it also aborts watchdog restarts + /// waiting out their 2 s delay. The console's KillAllApps RPC leaves them + /// pending, matching the real Agent's race behavior. + public void KillAllApps(bool cancelPendingRestarts = false) { + if (cancelPendingRestarts) + { + Interlocked.Increment(ref mWatchdogGeneration); + } List victims = RemoveLaunched(l => true); if (victims.Count > 0) { @@ -399,38 +491,6 @@ internal sealed class VirtualLauncher return killed; } - /// Drops launched entries whose real process has exited on its own - /// (crash or normal exit), so GetLaunchedApps/FullUpdate reflect liveness. - private void PruneExitedProcesses() - { - int pruned = 0; - lock (mLock) - { - for (int i = mLaunchedApps.Count - 1; i >= 0; i--) - { - if (!mRealProcesses.TryGetValue(mLaunchedApps[i].ProcessId, out Process process)) - { - continue; // simulated entry: lives until killed - } - bool exited; - try { exited = process.HasExited; } - catch { exited = true; } - if (exited) - { - mRealProcesses.Remove(mLaunchedApps[i].ProcessId); - process.Dispose(); - mLaunchedApps.RemoveAt(i); - pruned++; - } - } - } - if (pruned > 0) - { - Log?.Invoke($"{pruned} launched process(es) exited on their own."); - AppsChanged?.Invoke(); - } - } - public void Shutdown(bool restart) { Log?.Invoke(restart ? "Shutdown(restart) — power-cycling the pod." : "Shutdown — powering the pod off."); @@ -450,9 +510,11 @@ internal sealed class VirtualLauncher /// Clears the installed/launched app registries (LaunchApps.json) — /// the store-wipe half of ClearStore, also used by the UI's Reprovision. - /// Any real launched processes are terminated first. + /// Any real launched processes are terminated first, and pending watchdog + /// restarts cancelled. public void WipeApps() { + Interlocked.Increment(ref mWatchdogGeneration); KillRealProcesses(RemoveLaunched(l => true)); lock (mLock) {