diff --git a/src/D2BotNG/Engine/ProfileEngine.cs b/src/D2BotNG/Engine/ProfileEngine.cs
index 651be40..2755b68 100644
--- a/src/D2BotNG/Engine/ProfileEngine.cs
+++ b/src/D2BotNG/Engine/ProfileEngine.cs
@@ -141,6 +141,58 @@ private void RegisterHandle(ProfileInstance instance, nint handle)
/// of the manager. The sweep by name is belt-and-braces for an entry registered under a
/// different handle (e.g. restored from a handoff manifest recording a drifted top-level).
///
+ ///
+ /// Re-points a profile's routing entry at the window its game actually owns, if the one we
+ /// registered has gone stale. Returns true when something was repaired.
+ ///
+ ///
+ /// A wrong routing entry and a dead bot look identical from the watchdog's side: no
+ /// heartbeats arrive either way. The difference is that a wrong entry is ours to fix, and
+ /// killing a healthy game over it is the worst possible response — at fleet scale it is a
+ /// mass restart a minute after an update.
+ ///
+ /// The case this exists for is adoption. A successor restores the routing entry from the
+ /// predecessor's manifest, so it inherits whatever the predecessor believed — and a
+ /// predecessor built before the handle was tracked on the instance reverse-looked it up out
+ /// of a map that leaked a dead row per game exit, returning an arbitrary one. Every update
+ /// from such a build hands its successor a handle that may name a window that no longer
+ /// exists, which is not something the successor can fix by being correct itself.
+ ///
+ ///
+ private bool RepairRoutingIfStale(ProfileInstance instance, Process process)
+ {
+ nint liveWindow;
+ try
+ {
+ liveWindow = process.GameWindow;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not read game window for {Name} while checking routing", instance.ProfileName);
+ return false;
+ }
+
+ // No game window means there is nothing to route to — that is a real fault, not a
+ // routing problem, so let the watchdog handle it.
+ if (liveWindow == 0) return false;
+
+ if (instance.GameWindowHandle == liveWindow
+ && _handleToProfile.TryGetValue(liveWindow, out var mapped)
+ && mapped == instance.ProfileName)
+ {
+ return false;
+ }
+
+ _logger.LogWarning(
+ "Profile {Name} went quiet while routed to window {Registered}, but its game owns {Live} — " +
+ "re-registering; messages from it were being discarded",
+ instance.ProfileName, instance.GameWindowHandle, liveWindow);
+
+ UnregisterHandles(instance);
+ RegisterHandle(instance, liveWindow);
+ return true;
+ }
+
private void UnregisterHandles(ProfileInstance instance)
{
if (instance.GameWindowHandle != 0)
@@ -972,6 +1024,16 @@ private async Task MonitorProcessAsync(ProfileInstance instance, CancellationTok
}
var elapsed = (now - (instance.LastHeartbeat ?? instance.StartedAt!.Value)).TotalSeconds;
+ if (heartbeatEnabled && elapsed > heartbeatTimeout
+ && RepairRoutingIfStale(instance, process))
+ {
+ // We were listening on the wrong window, so the silence says nothing about
+ // the bot. Give it another interval on the repaired route before counting a
+ // miss, and re-push our handle in case the game is still aimed elsewhere.
+ process.SendMessage((MessageType)_messageWindow.Handle, "Handle");
+ elapsed = 0;
+ }
+
if (heartbeatEnabled && elapsed > heartbeatTimeout)
{
process.SendMessage((MessageType)_messageWindow.Handle, "Handle");
diff --git a/src/D2BotNG/UI/MainForm.cs b/src/D2BotNG/UI/MainForm.cs
index d140ecf..3ac37a2 100644
--- a/src/D2BotNG/UI/MainForm.cs
+++ b/src/D2BotNG/UI/MainForm.cs
@@ -2,6 +2,7 @@
using D2BotNG.Core.Protos;
using D2BotNG.Data;
using D2BotNG.Engine;
+using D2BotNG.Logging;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
using static D2BotNG.Windows.NativeMethods;
@@ -12,13 +13,16 @@ namespace D2BotNG.UI;
public class MainForm : Form
{
+ private static readonly Serilog.ILogger Logger = TrackingLoggerFactory.ForContext(typeof(MainForm));
+
// ReSharper disable InconsistentNaming — Win32 API constants
private const int WM_SYSCOMMAND = 0x112;
private const int SC_MINIMIZE = 0xF020;
private const int WM_EXITSIZEMOVE = 0x232;
// ReSharper restore InconsistentNaming
- private readonly WebView2 _webView;
+ // Not readonly: a failed initialization is retried on a fresh control (see RecreateWebView).
+ private WebView2 _webView;
private readonly NotifyIcon _trayIcon;
private readonly ContextMenuStrip _trayMenu;
private readonly string _serverUrl;
@@ -109,42 +113,126 @@ private bool ShouldMinimizeToTray()
}
private async void OnFormLoad(object? sender, EventArgs e)
+ {
+ // Retried rather than fatal. One WebView2 user data folder is served by one browser
+ // process, and a client whose browser process goes away mid-initialization gets
+ // RPC_E_DISCONNECTED (0x80010108). Handoff creates exactly that window: the successor
+ // builds its environment while the predecessor is still unwinding and releasing its own.
+ // Failing once used to leave a permanently blank window over a perfectly healthy server.
+ const int maxAttempts = 4;
+
+ while (true)
+ {
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ await InitializeWebViewAsync();
+ return;
+ }
+ catch (Exception ex) when (attempt < maxAttempts)
+ {
+ Logger.Warning(ex,
+ "WebView2 initialization attempt {Attempt}/{Max} failed, retrying", attempt, maxAttempts);
+
+ // Linear backoff: the predecessor's browser process needs long enough to
+ // finish exiting so our next attempt spawns a fresh one instead of
+ // attaching to the corpse.
+ await Task.Delay(TimeSpan.FromMilliseconds(750 * attempt));
+ RecreateWebView();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "WebView2 initialization failed after {Max} attempts", maxAttempts);
+
+ var choice = MessageBox.Show(
+ $"Failed to initialize WebView2: {ex.Message}\n\n" +
+ "The bot manager itself is still running and your profiles are unaffected — " +
+ "this is only the window.\n\n" +
+ "Retry, or Cancel to keep running without the UI (the web interface is still " +
+ $"available at {_serverUrl}).",
+ "Error",
+ MessageBoxButtons.RetryCancel,
+ MessageBoxIcon.Error);
+
+ if (choice != DialogResult.Retry) return;
+ RecreateWebView();
+ }
+ }
+ }
+ }
+
+ ///
+ /// Replaces the WebView2 control with a fresh one before an initialization retry.
+ ///
+ ///
+ /// A control remembers the environment handed to its first
+ /// EnsureCoreWebView2Async call and rejects a different one afterwards. Since a retry
+ /// exists precisely to get away from an environment whose browser process died, the control
+ /// has to go with it — otherwise the second attempt fails on the stale binding rather than
+ /// on whatever we were retrying.
+ ///
+ private void RecreateWebView()
{
try
{
- // Place WebView2's user data (cache, cookies, IndexedDB, crash dumps) under
- // %LOCALAPPDATA%\D2BotNG\WebView2 instead of next to the exe (which is the
- // default and litters the install directory with a *.exe.WebView2 folder).
- var userDataFolder = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "D2BotNG",
- "WebView2");
- Directory.CreateDirectory(userDataFolder);
- var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
- await _webView.EnsureCoreWebView2Async(env);
-
- // Configure WebView2
- _webView.CoreWebView2.Settings.IsStatusBarEnabled = false;
- _webView.CoreWebView2.Settings.AreDevToolsEnabled = true;
-
- // Listen for messages from web app
- _webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
-
- // Navigate to server
- _webView.CoreWebView2.Navigate(_serverUrl);
-
- // Bring window to front after WebView2 init
- Activate();
- BringToFront();
+ Controls.Remove(_webView);
+ _webView.Dispose();
}
catch (Exception ex)
{
- MessageBox.Show(
- $"Failed to initialize WebView2: {ex.Message}\n\nPlease ensure WebView2 Runtime is installed.",
- "Error",
- MessageBoxButtons.OK,
- MessageBoxIcon.Error);
+ Logger.Warning(ex, "Failed to dispose WebView2 control before retry");
}
+
+ _webView = new WebView2 { Dock = DockStyle.Fill };
+ Controls.Add(_webView);
+ }
+
+ private async Task InitializeWebViewAsync()
+ {
+ var env = await CoreWebView2Environment.CreateAsync(null, ResolveUserDataFolder());
+ await _webView.EnsureCoreWebView2Async(env);
+
+ // Configure WebView2
+ _webView.CoreWebView2.Settings.IsStatusBarEnabled = false;
+ _webView.CoreWebView2.Settings.AreDevToolsEnabled = true;
+
+ // Listen for messages from web app
+ _webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
+
+ // Navigate to server
+ _webView.CoreWebView2.Navigate(_serverUrl);
+
+ // Bring window to front after WebView2 init
+ Activate();
+ BringToFront();
+ }
+
+ ///
+ /// Where WebView2 keeps its cache, cookies, IndexedDB and crash dumps.
+ ///
+ ///
+ /// Under %LOCALAPPDATA%\D2BotNG rather than next to the exe, which is the default and
+ /// litters the install directory with a *.exe.WebView2 folder.
+ ///
+ /// Partitioned by server port so that several managers running side by side on one machine
+ /// don't share a browser process. They used to: one folder means one browser process serving
+ /// every instance, so whichever one owned it could take the others' windows down with it
+ /// when it exited. The port is the right key because two managers cannot run concurrently on
+ /// the same one — they'd fail to bind. A predecessor and its successor DO share a port, and
+ /// deliberately share the folder with it; that overlap is what the retry above covers.
+ ///
+ ///
+ private string ResolveUserDataFolder()
+ {
+ var port = new Uri(_serverUrl).Port;
+ var folder = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "D2BotNG",
+ "WebView2",
+ port.ToString());
+ Directory.CreateDirectory(folder);
+ return folder;
}
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)