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)
{