Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/D2BotNG/Engine/ProfileEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
/// </summary>
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
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)
Expand Down Expand Up @@ -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");
Expand Down
148 changes: 118 additions & 30 deletions src/D2BotNG/UI/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
}
}
}

/// <summary>
/// Replaces the WebView2 control with a fresh one before an initialization retry.
/// </summary>
/// <remarks>
/// A control remembers the environment handed to its first
/// <c>EnsureCoreWebView2Async</c> 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.
/// </remarks>
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();
}

/// <summary>
/// Where WebView2 keeps its cache, cookies, IndexedDB and crash dumps.
/// </summary>
/// <remarks>
/// Under %LOCALAPPDATA%\D2BotNG rather than next to the exe, which is the default and
/// litters the install directory with a *.exe.WebView2 folder.
/// <para>
/// 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.
/// </para>
/// </remarks>
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)
Expand Down
Loading