Console: survive a launcher-dropped connection during product installs

The launcher drops RPC sessions idle >30s (easily hit while the operator sits
in the install dialogs), but a dropped socket still reports Connected until an
I/O fails. InstallProductWorker then skipped its reopen, streamed the archive
on the fresh out-of-band connection, and died on the first progress poll —
and the install-completed handler registered launch entries without checking
e.Error, so AddApp's disconnected-guard exception crashed the whole console.

- PodInfo.EnsureConnectionAlive: probe an "open" connection with a Ping and
  reconnect when it is dead; used by the install and uninstall workers.
- SiteManagement.PodInfo_InstallProductCompleted: register launch entries only
  on success, and surface registration failures as the row's Install Failed
  state instead of an unhandled exception.
- Regression test pins the premise + recovery against vPOD's launcher server
  (stale socket reports open, Ping exposes it, reconnect restores service).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-09 21:40:14 -05:00
co-authored by Claude Fable 5
parent 1daaf4af78
commit e0a3c72370
3 changed files with 74 additions and 11 deletions
+35 -8
View File
@@ -617,6 +617,39 @@ public class PodInfo : Component
asyncControlState.asyncOp.PostOperationCompleted(installProductDelegates.OnCompleted, arg);
}
/// <summary>
/// Opens the management connection if needed. A connection the launcher has
/// dropped still reports IsOpen (Connected is stale until an I/O fails), and
/// the launcher drops sessions idle for ~30s — easily hit while the operator
/// sits in the install dialogs — so probe an "open" connection with a Ping
/// and reconnect when it turns out to be dead.
/// </summary>
private void EnsureConnectionAlive()
{
if (mConnection.IsOpen)
{
try
{
mConnection.Ping(DateTime.Now);
return;
}
catch (Exception)
{
try
{
mConnection.Close();
}
catch
{
}
}
}
if (!mConnection.IsOpen)
{
mConnection.Open(new IPEndPoint(mPod.IPAddress, ManagePort), mPod.Key);
}
}
private void InstallProductWorker(string filePath, SendOrPostCallback progressCallback, AsyncOperation asyncOp, SendOrPostCallback completionMethodDelegate)
{
Exception ex = null;
@@ -624,10 +657,7 @@ public class PodInfo : Component
{
for (int i = 0; i < 3; i++)
{
if (!mConnection.IsOpen)
{
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
}
EnsureConnectionAlive();
Guid guid = mConnection.InstallProduct(filePath);
if (guid == Guid.Empty)
{
@@ -709,10 +739,7 @@ public class PodInfo : Component
Exception ex = null;
try
{
if (!mConnection.IsOpen)
{
mConnection.Open(new IPEndPoint(mPod.IPAddress, 53290), mPod.Key);
}
EnsureConnectionAlive();
mConnection.UninstallApp(launchKey);
mAllApps.Expire();
}
+18 -3
View File
@@ -1138,12 +1138,27 @@ public class SiteManagement : Form
{
Tuple<PodData, Guid> tuple = e.UserState as Tuple<PodData, Guid>;
PodData a = tuple.A;
foreach (LaunchData appData in BuildLaunchData(tuple.B, a))
Exception error = e.Error;
// Register the launch entries only when the transfer succeeded, and never
// let a pod that dropped mid-install take the console down (AddApp throws
// when the connection is gone).
if (error == null)
{
a.Conn.AddApp(appData);
try
{
foreach (LaunchData appData in BuildLaunchData(tuple.B, a))
{
a.Conn.AddApp(appData);
}
}
catch (Exception ex)
{
Program.LogException(ex, "Registering launch entries failed.");
error = ex;
}
}
a.OperationInProgress = false;
a.Error = e.Error;
a.Error = error;
dgvPods.InvalidateCell(colOperationProgress.Index, mPods.IndexOf(a));
}
@@ -199,6 +199,27 @@ namespace TeslaConsole.DiffTests
Assert.Empty(mLauncher.GetInstalledApps());
}
[Fact]
public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works()
{
// The launcher (and vPOD) drops sessions idle >30s. Simulate the drop
// server-side and pin the premise PodInfo.EnsureConnectionAlive relies
// on: the client socket still REPORTS open until an I/O fails...
mServer.Stop();
mServer.Start(mKey);
Thread.Sleep(100); // let the FIN arrive
Assert.True(mClient.IsOpen, "a dropped-but-unused connection should still report open (the stale state)");
// ...the cheap Ping probe is what exposes the dead connection...
Assert.ThrowsAny<Exception>(() => mClient.Ping(DateTime.UtcNow));
// ...and close + reopen (EnsureConnectionAlive's recovery) restores service.
mClient.Close();
mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now, mClient.Ping(now));
}
[Fact]
public void Installed_Apps_Persist_Across_Launcher_Restart()
{