vPOD: real-launch auto-restart watchdog + optional postinstall.bat

Two additions to the virtual launcher's real-process mode:

- Auto-restart watchdog. Replaces the poll-on-query PruneExitedProcesses
  with a per-process watcher thread (StartWatcher): when a real-launched
  app exits on its own -- not via a Kill*/Uninstall, which untrack it
  first -- it is dropped from the running list and, if its LaunchData has
  AutoRestart and the "Auto-restart after the app exits (watchdog)"
  toggle is on, relaunched after the Agent's 2 s delay. A watchdog
  generation counter cancels pending restarts when the pod goes dark
  (power off / reboot / reprovision / WipeApps); the console's KillAllApps
  leaves them pending, matching the real Agent's race.

- postinstall.bat toggle. A "Run postinstall.bat after install" checkbox
  (above "Actually launch apps", off by default) makes an install execute
  a packaged postinstall.bat via cmd /c (waited up to 5 min) before
  deleting it, like the real service. Off, it is logged and removed unrun
  as before -- it runs package script code on the host.

Both are opt-in from the vPOD window. Verified against the real
LauncherRpcServer over a loopback socket: the watchdog test relaunches an
exited ping.exe with a new PID and stops once toggled off; a crafted
package's postinstall.bat runs (and is removed) only when enabled. Full
differential suite 103/103.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-10 16:30:28 -05:00
co-authored by Claude Fable 5
parent 80ee1d26ea
commit 13f8e0456b
5 changed files with 244 additions and 55 deletions
@@ -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()
{
+43 -5
View File
@@ -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 <see cref="VirtualLauncher" />; unlike the real service,
/// postinstall.bat from a package is logged but never executed.
/// All state lives in <see cref="VirtualLauncher" />; a packaged postinstall.bat
/// is logged and removed unrun unless <see cref="VirtualLauncher.RunPostInstall" />
/// is set from the vPOD window, in which case it is executed like the real service.
/// </summary>
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 { }
}
+14 -9
View File
@@ -110,17 +110,24 @@ launch entries point — and reports the launcher's usual `050%` 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\<product>` 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:
+45 -5
View File
@@ -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
}
/// <summary>
@@ -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)
{
+98 -36
View File
@@ -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.</summary>
public bool RealLaunch { get; set; }
/// <summary>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.</summary>
public bool RealAutoRestart { get; set; } = true;
/// <summary>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.</summary>
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<string> Log;
public event Action AppsChanged; // installed or launched list changed
public event Action<Guid, OutOfBandProgress> 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;
}
/// <summary>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.</summary>
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<LaunchedAppData> victims = RemoveLaunched(l => l.LaunchKey == launchKey && l.ProcessId == processId);
@@ -331,8 +415,16 @@ internal sealed class VirtualLauncher
}
}
public void KillAllApps()
/// <summary>Kills everything launched. <paramref name="cancelPendingRestarts" />
/// 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.</summary>
public void KillAllApps(bool cancelPendingRestarts = false)
{
if (cancelPendingRestarts)
{
Interlocked.Increment(ref mWatchdogGeneration);
}
List<LaunchedAppData> victims = RemoveLaunched(l => true);
if (victims.Count > 0)
{
@@ -399,38 +491,6 @@ internal sealed class VirtualLauncher
return killed;
}
/// <summary>Drops launched entries whose real process has exited on its own
/// (crash or normal exit), so GetLaunchedApps/FullUpdate reflect liveness.</summary>
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
/// <summary>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.</summary>
/// Any real launched processes are terminated first, and pending watchdog
/// restarts cancelled.</summary>
public void WipeApps()
{
Interlocked.Increment(ref mWatchdogGeneration);
KillRealProcesses(RemoveLaunched(l => true));
lock (mLock)
{