From e0a3c72370dc5c8246b60e3798633fe3c70ade69 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 9 Jul 2026 21:40:14 -0500 Subject: [PATCH] Console: survive a launcher-dropped connection during product installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Console/TeslaConsole/PodInfo.cs | 43 +++++++++++++++---- Console/TeslaConsole/SiteManagement.cs | 21 +++++++-- .../VPodLauncherServerTests.cs | 21 +++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/Console/TeslaConsole/PodInfo.cs b/Console/TeslaConsole/PodInfo.cs index 278886a..f537996 100644 --- a/Console/TeslaConsole/PodInfo.cs +++ b/Console/TeslaConsole/PodInfo.cs @@ -617,6 +617,39 @@ public class PodInfo : Component asyncControlState.asyncOp.PostOperationCompleted(installProductDelegates.OnCompleted, arg); } + /// + /// 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. + /// + 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(); } diff --git a/Console/TeslaConsole/SiteManagement.cs b/Console/TeslaConsole/SiteManagement.cs index d1cf2b7..5b9398b 100644 --- a/Console/TeslaConsole/SiteManagement.cs +++ b/Console/TeslaConsole/SiteManagement.cs @@ -1138,12 +1138,27 @@ public class SiteManagement : Form { Tuple tuple = e.UserState as Tuple; 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)); } diff --git a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs index db42df7..98fde8d 100644 --- a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs +++ b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs @@ -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(() => 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() {