Skip to content

Commit d66d58a

Browse files
committed
Multi-site: keep external sites monitoring through tunnel outages, without data loss or UI hangs (#978)
* Agent: keep collecting through tunnel outages, replay buffered results on reconnect Probe and SNMP runners now live for the whole agent process instead of per tunnel connection, feeding a store-and-forward ResultBuffer (12 h / 64 MB caps, oldest dropped first). While the tunnel is down the agent keeps polling on the last pushed config - previously the runners were torn down with the connection and the outage window was simply lost. On reconnect the backlog drains in FIFO order, coalescing consecutive batches to cut server-side per-batch overhead and throttling to keep channel headroom for heartbeats and proxied console traffic. Teardown salvages unsent messages from the outbound channel so a drop or failed connect loses nothing. Server side: custom-OID results now land at their sample timestamps instead of write time (a replayed backlog would have collapsed onto the flush time), and samples older than 10 minutes skip the alert state machines so a replayed down/up sequence doesn't fire alerts hours late. Interface rates come out correct across the gap for free - the counter delta cache persists through the outage and samples carry their own timestamps. * Tunnel liveness watchdogs: fail fast on black-holed agent tunnels A WAN outage black-holes the tunnel TCP connection rather than resetting it, and neither side noticed for the full TCP timeout (~15 min). Server side that meant the registry kept the dead connection, IsAgentOnline() stayed true, the console never flipped to awaiting-agent, and every page of the site - including a site switch - hung on proxy opens into the dead tunnel. Server: drop a connection silent past 90 s (agents heartbeat every 30 s; LastMessageAt is stamped on every message). The drop runs the normal teardown, so the awaiting-agent fail-fast now engages within ~2 min of a real outage instead of ~15. Agent: force a reconnect when nothing arrives for 150 s (the server re-pushes configs every 60 s, so a healthy tunnel is never that quiet). Without this the agent's read loop also hung on dead TCP, delaying the reconnect - and the buffered-backlog flush - by the same ~15 min. Forced reconnects lose nothing: the outbound channel is salvaged and results keep buffering. * Site-scope LAN flow map view state; reload PWA on stale resume Two multi-site UX fixes: Map view state (camera, overlays, scrub span, signal-hint dismissal) was keyed by a fixed localStorage prefix shared across every site, so switching sites inherited the previous site's 3D camera and 2D overlays. SiteContextService.ScopeStorageKey now prefixes the key with the site slug for non-default sites; the default site keeps the bare key so existing saved state and single-site installs are untouched. Applied at all four map mount sites (Monitoring 3D/2D, dashboard Live View 3D/2D). PWA resume: backgrounding the app on mobile suspends the tab and kills its Blazor WebSocket, but on resume Blazor often doesn't surface the reconnect modal, leaving the dashboard on its stale prerender with frozen loading spinners until a manual refresh. A visibilitychange handler now reloads when the tab was hidden past ~30 s and Blazor isn't already handling the drop, so a long-backgrounded page recovers on its own. * Fast-fail proxy opens on a black-holed agent tunnel A dead-but-registered tunnel (WAN black-hole, before the 90s server watchdog drops it) left every console call dialing the loopback proxy and blocking the full OpenTimeout for an answer that never comes, so a site switch or page load froze the browser for up to ~90s. - Refuse the proxy connect immediately when the agent has been silent past 45s (well above the 30s heartbeat, below the 90s watchdog). - Shorten OpenTimeout 10s to 3s; a live agent answers a ProxyOpen in well under a second. * 2D LAN flow map: keep bottom devices clear of the scrubber bar _fitAll centered on the raw canvas midpoint, so the desktop scrubber bar (which overlays the stage bottom) clipped the lowest devices. Reserve the bar's height as a bottom inset and center in the timeline-free region, lifting the map a touch. Mobile is unchanged (the scrubber sits below the stage there). * 2D LAN flow map: tighten the scrubber-bar clearance The first pass reserved the bar height plus a breather plus the bottom margin, leaving ~16px of dead space above the bar. Center in a proper [top margin .. just above the bar] band with a 4px gap instead. Mobile is unchanged (no overlay, so the band is symmetric top/bottom). * Agent: bound the pre-hello handshake so a black-hole can't wedge it The inbound-silence watchdog only arms after the server hello arrives, so a link black-holed in the connect->hello window would hang the read on dead TCP for the full ~15 min OS timeout before the reconnect loop could retry. Cap the handshake at 20s and fail fast into a retry. * Agent buffer: ack-based store-and-forward so black-holes don't lose data The buffer dropped a message as soon as WriteAsync returned, but TCP reports a write into a black-holed connection as success (the bytes sit in the kernel send buffer and are discarded on teardown). So during a WAN black-hole the buffer drained into the void and an outage's worth of monitoring data was lost - confirmed by a DROP test: zero latency points landed for the 2-minute outage window on either external site. Now a result frame stays buffered until the SERVER acks it: - Each frame carries a monotonic sequence; the server sends a cumulative ResultAck once the batch is persisted. - The drain only PEEKS frames (never removes on send); MarkAcked trims the acked run, and an unacked frame replays from the oldest on reconnect. This deletes the salvage machinery (SalvageUnsentInto, _pumpInFlight, RequeueFront) - nothing to salvage when the buffer already retains everything unacked. Rolling-upgrade safe via a ServerHello.supports_result_ack capability: a new agent against an older (non-acking) server falls back to trim-on-send so it doesn't retain and re-flush its whole buffer every reconnect. Old agent + new server is unaffected (sequence 0 => no ack). * PWA resume: probe the circuit before reloading, don't reload on a timer The visibilitychange handler reloaded whenever the tab was hidden past 30s, discarding scroll position and in-page state even when the Blazor circuit had survived the backgrounding. Now it round-trips a lightweight [JSInvokable] Ping on resume and only reloads when the circuit is genuinely dead (the probe times out); a live circuit resumes seamlessly. * Live WAN chart: overflow visible so it isn't clipped by the card The base .card clips to its rounded corners (overflow hidden), cutting off the live WAN chart's overhang on both the dashboard and the Monitoring Live View. Scope overflow: visible to the WAN chart card and its body. * Proxy: circuit-breaker so a site switch during an outage doesn't hang The liveness gate can't trip until 45s (it must stay above the 30s heartbeat to avoid false-failing a healthy tunnel), so in the first ~45s of a black-hole every proxy open still ate the full 3s OpenTimeout. A site switch fires a burst of console + SSH opens that serialize, so the switch hung ~15s until the gate caught up. Now the first open timeout trips a per-site breaker; opens queued behind it fast-fail instantly instead of each blocking OpenTimeout, collapsing the burst to a single timeout. The breaker self-clears the moment the tunnel produces fresh inbound (recovery) - keyed on LastMessageAt, so no probe is needed - or when the 45s gate takes over. * Proxy: hold the open breaker past the liveness gate, no mid-outage re-probe The breaker's 15s hold expired mid-outage and let a fresh burst of opens time out (3s each) before re-tripping - a ~10s stall on any site switch that landed in that window, recurring every ~15s until the 45s gate took over. Hold the breaker 60s so it stays tripped continuously until the gate handles it; it still clears the instant fresh inbound arrives, so a healthy tunnel is never held (its next heartbeat lets opens through). * Live View: show a console-down banner instead of "not configured" on outage A down console (agent-tunnel outage) made the gateway device fetch throw, which flipped the whole panel to the "Monitoring not configured / set up monitoring" empty state - wrong and confusing, since monitoring IS configured and the console is just unreachable. Isolate that fetch so it no longer blanks the panel, and surface a console-down / awaiting-agent banner (with the connection's own message) so the staleness is explained. * Live WAN chart: periodically re-pull history to fill post-outage/startup gaps pollLive only appends the latest sample forward, and loadHistory ran only on mount and tab-refocus - so data backfilled into Influx after the buffer scrolled past that time (an outage's buffered samples replaying on reconnect, or the cold-start gap before data first flowed) never appeared. A 60s backfill timer re-pulls the window and merges it, keeping the newest live samples ahead of the re-pulled history so the smooth live edge never regresses. Live mode + foreground only; stopped during historic playback and teardown. * Console: fail fast on a black-holed agent tunnel instead of a 14s retry A console reached via an agent tunnel is dialed through a loopback proxy. When the tunnel is black-holed the proxy fast-fails the open, but the HTTP client's transient-failure retry sits ABOVE the proxy and stacked the full 2+4+8s (~14s) exponential backoff onto every request - so a switch to that site hung ~10-15s for the whole ~90s before the watchdog flipped the console to awaiting-agent. Agent-proxied consoles (loopback 127.0.0.1) now get a single quick retry; directly-connected consoles keep the full backoff, so a real transient blip there is still ridden out. * Console: flip to awaiting-agent on the proxy's dead-tunnel signal Retry-scoping cut per-call cost from ~14s to ~0.5s, but a site switch still ran ~12 console calls while the console reported connected (stale green) during the ~90s before the watchdog flips it - so the switch still took ~9s. Now the tunnel proxy, on the first open timeout of an outage, tells the site's UniFiConnectionService to flip to awaiting-agent immediately, so page renders short-circuit console calls at the IsConnected guard instead of each dialing the dead proxy. The agent is still registered so OnAgentTunnelDroppedAsync can't fire yet; this is a separate path that skips that bail, is idempotent, and the agent-connected hook re-establishes the console on recovery. * Banners: dismiss app-wide nudges globally, not per-site SystemSettingsService.GetAsync/SetAsync route to the CURRENT site's DB, so a banner dismissed on one site reappeared when switching sites. The PWA install nudge and the WiFi channel-analysis disclaimer are feature-level (not site data), so dismiss them globally via GetGlobalAsync/SetGlobalAsync, matching the prerelease-updates and iperf3-preferences convention. Site-specific dismissals (upstream-discovery results, per-site speed-test schedule nudges) stay per-site. * Gate console wait/connect on tunnel LIVENESS, not registration The real source of the slow site switch during a black-hole: every page awaits WaitForConnectionAsync before rendering, and its only agent early-out checked registration (IsAgentOnline). A black-holed tunnel stays registered until the 90s watchdog, so the early-out never fired and every page load polled the full 3s timeout - twice per navigation (prerender + interactive) = ~9s per switch, ~13s with the first breaker probe, and instant only once the watchdog unregistered the agent. The proxy/retry fixes never moved this because the render was stalled in the poll, not the dials. - AgentTunnelConnection gains the shared staleness primitive (StaleThreshold 45s / IsStale); the proxy's private copy is replaced. - WaitForConnectionAsync bails immediately on IsAwaitingAgent and treats a dead-but-registered tunnel as absent (HasLiveAgentTunnel). - The connect paths and Test Connection use the same liveness check, so reconnect attempts during the interim land in awaiting-agent instead of dialing the dead proxy and misreporting an SSL error. - The 60s config refresh no longer un-flips awaiting-agent mid-outage: ReconnectConsoleIfViaAgentAsync skips a stale connection (the log showed it "reconnecting its console" through the dead tunnel every 60s, resurrecting the delay the flip had just removed). Registration-only checks remain only where they belong (teardown bookkeeping in OnAgentTunnelDroppedAsync). * Flip an outaged site's console proactively, not on first dial The awaiting-agent flip only fired from the proxy-open TIMEOUT path, so a site nobody touched during the outage never flipped: its opens were refused by the stale gate (which didn't flip), its console stayed stale-green, and the first switch to it paid a dial-plus-retry on every console call - ~9s per page load - until the 90s watchdog unregistered the agent. The user's own site flipped early only because its open pages happened to dial it. - The tunnel watchdog now flips the site's console the moment silence crosses the stale threshold (checked every 15s), so every site of a black-holed agent goes awaiting-agent by ~60s with no dial needed. - The proxy's stale-gate refusal also fires the flip (belt-and-braces for a dial landing between watchdog ticks). - NoteProxyUnreachableAsync renamed NoteTunnelUnreachableAsync; the proxy's two flip paths share one helper. Coverage: 0-45s = breaker flip on first timeout (~3s); 45s+ = proactive watchdog flip; dial-before-tick = stale-gate flip. Recovery unchanged (agent reconnect re-establishes the console). * Report awaiting-agent, not a bogus SSL error, when a connect hits a dead tunnel A connect attempt against a black-holed tunnel collapses mid-TLS at the loopback proxy and parsed as "SSL certificate error: enable Ignore SSL Errors" - advice that can't apply to an agent-proxied console (those force ignore-SSL; no certificate can match 127.0.0.1). The banner flashed that error until the awaiting-agent flip corrected it. After a failed connect, if the console is agent-routed and the proxy says the tunnel is suspect (no agent, stale, or open-breaker tripped with no fresh inbound), land in awaiting-agent with its message instead. A genuine console-side failure over a healthy tunnel keeps its real error. * Settings: show Ignore SSL Errors as forced-on for agent-connected consoles The setting can't apply through the agent tunnel (the console is dialed at a loopback proxy endpoint its certificate can never match), and the code has always forced it on for that path. Show the checkbox checked+disabled with verbiage explaining why, instead of implying the toggle does something. Directly-connected consoles keep the normal toggle. * Settings: trim the forced-on SSL help text
1 parent 6ea5b45 commit d66d58a

26 files changed

Lines changed: 1371 additions & 119 deletions

NetworkOptimizer.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.Agent", "s
5959
EndProject
6060
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.AgentProtocol", "src\NetworkOptimizer.AgentProtocol\NetworkOptimizer.AgentProtocol.csproj", "{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}"
6161
EndProject
62+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.AgentProtocol.Tests", "tests\NetworkOptimizer.AgentProtocol.Tests\NetworkOptimizer.AgentProtocol.Tests.csproj", "{EC6241C2-8CD8-4321-B93C-3ACC408050E9}"
63+
EndProject
6264
Global
6365
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6466
Debug|Any CPU = Debug|Any CPU
@@ -381,6 +383,18 @@ Global
381383
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x64.Build.0 = Release|Any CPU
382384
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x86.ActiveCfg = Release|Any CPU
383385
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x86.Build.0 = Release|Any CPU
386+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
387+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
388+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x64.ActiveCfg = Debug|Any CPU
389+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x64.Build.0 = Debug|Any CPU
390+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x86.ActiveCfg = Debug|Any CPU
391+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x86.Build.0 = Debug|Any CPU
392+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
393+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|Any CPU.Build.0 = Release|Any CPU
394+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x64.ActiveCfg = Release|Any CPU
395+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x64.Build.0 = Release|Any CPU
396+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x86.ActiveCfg = Release|Any CPU
397+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x86.Build.0 = Release|Any CPU
384398
EndGlobalSection
385399
GlobalSection(SolutionProperties) = preSolution
386400
HideSolutionNode = FALSE
@@ -401,6 +415,7 @@ Global
401415
{EC72C9AD-625C-4AA8-A7CC-744515E06F1E} = {5691A6DD-53B9-4CE0-A3C9-3D4F815E2120}
402416
{01F2AE32-FE55-4892-B91B-CE2BC964F29D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
403417
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
418+
{EC6241C2-8CD8-4321-B93C-3ACC408050E9} = {5691A6DD-53B9-4CE0-A3C9-3D4F815E2120}
404419
EndGlobalSection
405420
GlobalSection(ExtensibilityGlobals) = postSolution
406421
SolutionGuid = {F7E6D5C4-B3A2-9180-7F6E-5D4C3B2A1908}

src/NetworkOptimizer.Agent/ProbeRunner.cs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ namespace NetworkOptimizer.Agent;
1010
/// The site's latency/loss probe engine. The server pushes the site's
1111
/// monitoring targets over the tunnel; this runner probes each on its own
1212
/// cadence (2 s scheduler tick, per-target intervals, bounded concurrency,
13-
/// mirroring the server's latency tier) and streams every result straight
14-
/// back. Created per tunnel connection: the server re-pushes config on every
15-
/// connect, and results have nowhere to go while the tunnel is down.
13+
/// mirroring the server's latency tier) and enqueues every result into the
14+
/// store-and-forward <see cref="ResultBuffer"/>. Runs for the life of the
15+
/// agent process: probing continues through tunnel outages - which is exactly
16+
/// when latency/loss data matters most - with the buffer holding the backlog
17+
/// and the last pushed target set staying in effect until the server
18+
/// re-pushes on reconnect.
1619
/// </summary>
1720
public sealed class ProbeRunner
1821
{
19-
private readonly Func<AgentMessage, bool> _send;
22+
private readonly Action<AgentMessage> _send;
2023
private readonly string? _defaultSourceIp;
2124
private readonly LocalProbeExecutor _executor = new(NullLogger<LocalProbeExecutor>.Instance);
2225
private readonly ConcurrentDictionary<string, DateTime> _lastProbed = new();
@@ -27,7 +30,7 @@ public sealed class ProbeRunner
2730
/// own. This is the probe-only multi-WAN deployment knob: the gateway
2831
/// policy-routes this source IP out a specific WAN.
2932
/// </param>
30-
public ProbeRunner(Func<AgentMessage, bool> send, string? defaultSourceIp = null)
33+
public ProbeRunner(Action<AgentMessage> send, string? defaultSourceIp = null)
3134
{
3235
_send = send;
3336
_defaultSourceIp = string.IsNullOrEmpty(defaultSourceIp) ? null : defaultSourceIp;
@@ -42,13 +45,13 @@ public void UpdateConfig(ProbeConfig config)
4245
public async Task RunAsync(CancellationToken ct)
4346
{
4447
// Startup grace, mirroring the server latency tier's 20s initial delay
45-
// (MonitoringCollectionAgent.RunTierAsync). A fresh ProbeRunner starts on every
46-
// tunnel connect - the agent (re)starting, OR a NO-server restart that dropped
47-
// and re-established the tunnel - and every target is "due" immediately. Probing
48-
// the instant the process/link comes up catches the network still settling
49-
// (routes, source-IP binds, the target device itself rebooting alongside) and
50-
// records a false loss/latency spike right at the restart boundary. Wait for it
51-
// to settle before the first tick, so no boundary sample is taken.
48+
// (MonitoringCollectionAgent.RunTierAsync). At agent process start every target
49+
// is "due" immediately, and probing the instant the process comes up catches
50+
// the network still settling (routes, source-IP binds, the target device itself
51+
// rebooting alongside) and records a false loss/latency spike right at the
52+
// start boundary. Wait for it to settle before the first tick, so no boundary
53+
// sample is taken. Tunnel reconnects no longer restart this runner, so no
54+
// grace (or gap) applies there - probing runs continuously across outages.
5255
try { await Task.Delay(TimeSpan.FromSeconds(20), ct); }
5356
catch (OperationCanceledException) { return; }
5457

src/NetworkOptimizer.Agent/Program.cs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Runtime.InteropServices;
44
using System.Text.Json;
55
using NetworkOptimizer.Agent;
6+
using NetworkOptimizer.AgentProtocol;
67

78
// On-site agent skeleton: enrolls against the central Network Optimizer server
89
// with a one-time token, persists its agent key and site slug, then heartbeats.
@@ -185,26 +186,41 @@ static bool IsHttpsUrl(string url) =>
185186
speedTestServer is { } relayServer ? relayServer.PostIperf3ResultAsync : null, cts.Token);
186187
}
187188

189+
// Monitoring collectors and their store-and-forward buffer live for the whole
190+
// process, not per tunnel connection: probing and SNMP polling continue on the
191+
// last pushed config while the tunnel is down - exactly when outage data
192+
// matters most - and the buffered backlog (12 h / 64 MB caps, oldest dropped
193+
// first) replays over the tunnel on reconnect. Only started when a tunnel is
194+
// configured; without one no config ever arrives and there is nothing to run.
195+
ResultBuffer? resultBuffer = null;
196+
ProbeRunner? probeRunner = null;
197+
SnmpRunner? snmpRunner = null;
198+
Task? probeTask = null;
199+
Task? snmpTask = null;
200+
if (!string.IsNullOrEmpty(config.TunnelUrl))
201+
{
202+
resultBuffer = new ResultBuffer();
203+
probeRunner = new ProbeRunner(resultBuffer.Enqueue, config.ProbeSourceIp);
204+
snmpRunner = new SnmpRunner(resultBuffer.Enqueue);
205+
probeTask = probeRunner.RunAsync(cts.Token);
206+
snmpTask = snmpRunner.RunAsync(cts.Token);
207+
}
208+
188209
// Prefer the persistent gRPC tunnel; REST heartbeats keep the agent visible as
189210
// Online whenever the tunnel is unavailable (tunnel disabled server-side, an
190211
// older server, or a network drop between reconnect attempts).
191212
while (!cts.IsCancellationRequested)
192213
{
193214
if (!string.IsNullOrEmpty(config.TunnelUrl))
194215
{
195-
// The probe runner lives and dies with its tunnel connection: the
196-
// server re-pushes probe config on every connect, and results have
197-
// nowhere to go while the tunnel is down.
198216
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
199217
var tunnel = new TunnelClient();
200-
var probeRunner = new ProbeRunner(tunnel.TrySend, config.ProbeSourceIp);
201-
var snmpRunner = new SnmpRunner(tunnel);
202218
var proxyHandler = new ProxyHandler(tunnel, proxyPolicy);
203219
var iperf3ClientRunner = new Iperf3ClientRunner(tunnel);
204220
var uwnClientRunner = new UwnClientRunner(tunnel);
205221
var probeRequestRunner = new ProbeRequestRunner(tunnel);
206-
tunnel.OnProbeConfig = probeRunner.UpdateConfig;
207-
tunnel.OnSnmpConfig = snmpRunner.UpdateConfig;
222+
tunnel.OnProbeConfig = probeRunner!.UpdateConfig;
223+
tunnel.OnSnmpConfig = snmpRunner!.UpdateConfig;
208224
tunnel.OnWanSpeedTestConfig = wanConfig => speedTestServer?.UpdateWanServers(
209225
wanConfig.Servers.Select(s => new SpeedTestServer.WanServerEntry(s.ServerId, s.Url)).ToList(),
210226
wanConfig.DefaultServerId);
@@ -215,8 +231,17 @@ static bool IsHttpsUrl(string url) =>
215231
tunnel.OnIperf3Request = iperf3ClientRunner.HandleAsync;
216232
tunnel.OnUwnRequest = uwnClientRunner.HandleAsync;
217233
tunnel.OnProbeRequest = probeRequestRunner.HandleAsync;
218-
var probeTask = probeRunner.RunAsync(connectionCts.Token);
219-
var snmpTask = snmpRunner.RunAsync(connectionCts.Token);
234+
// Server confirms persisted result frames; trim them from the buffer so
235+
// only unacked data is retained for replay.
236+
tunnel.OnResultAck = seq => resultBuffer!.MarkAcked(seq);
237+
var backlog = resultBuffer!.Count;
238+
if (backlog > 0)
239+
Console.WriteLine($"Buffered backlog: {backlog} result message(s) (~{resultBuffer.ApproxBytes / 1024} KB) will flush once the tunnel connects");
240+
var droppedOffline = resultBuffer.TakeDroppedCount();
241+
if (droppedOffline > 0)
242+
Console.Error.WriteLine($"Result buffer caps dropped the {droppedOffline} oldest message(s) while the tunnel was down");
243+
244+
var drainTask = tunnel.DrainResultsAsync(resultBuffer, connectionCts.Token);
220245
try
221246
{
222247
await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, config.IgnoreSslErrors, cts.Token);
@@ -233,7 +258,9 @@ static bool IsHttpsUrl(string url) =>
233258
finally
234259
{
235260
connectionCts.Cancel();
236-
try { await Task.WhenAll(probeTask, snmpTask); } catch (OperationCanceledException) { }
261+
// Nothing to salvage: the drain only peeks, so every unacked frame is
262+
// still in the buffer and replays on the next connection.
263+
try { await drainTask; } catch (OperationCanceledException) { }
237264
}
238265
}
239266

@@ -270,6 +297,14 @@ static bool IsHttpsUrl(string url) =>
270297
{
271298
try { await iperf3Task; } catch (OperationCanceledException) { }
272299
}
300+
if (probeTask != null)
301+
{
302+
try { await probeTask; } catch (OperationCanceledException) { }
303+
}
304+
if (snmpTask != null)
305+
{
306+
try { await snmpTask; } catch (OperationCanceledException) { }
307+
}
273308

274309
Console.WriteLine("Agent stopped");
275310
return 0;

src/NetworkOptimizer.Agent/SnmpRunner.cs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ namespace NetworkOptimizer.Agent;
88
/// <summary>
99
/// The site's SNMP poller. The server pushes the site's SNMP credentials and
1010
/// device list over the tunnel; this runner polls interface counters (fast
11-
/// cadence) and device health (medium cadence) locally and streams the raw
12-
/// samples back. Rate computation and storage happen server-side, mirroring
13-
/// the central collection agent's split. Lives and dies with its tunnel
14-
/// connection, like the probe runner.
11+
/// cadence) and device health (medium cadence) locally and enqueues the raw
12+
/// samples into the store-and-forward <see cref="ResultBuffer"/>. Rate
13+
/// computation and storage happen server-side, mirroring the central
14+
/// collection agent's split. Runs for the life of the agent process, like the
15+
/// probe runner: polling continues through tunnel outages on the last pushed
16+
/// config, and the buffered backlog replays on reconnect.
1517
/// </summary>
1618
public sealed class SnmpRunner
1719
{
18-
private readonly TunnelClient _tunnel;
20+
private readonly Action<AgentMessage> _send;
1921
private volatile SnmpConfig? _config;
2022
private SnmpPoller? _poller;
2123
private string _pollerKey = "";
@@ -25,9 +27,9 @@ public sealed class SnmpRunner
2527
// the agent either. Keyed by device MAC.
2628
private readonly SnmpFailureTracker _failures = new();
2729

28-
public SnmpRunner(TunnelClient tunnel)
30+
public SnmpRunner(Action<AgentMessage> send)
2931
{
30-
_tunnel = tunnel;
32+
_send = send;
3133
}
3234

3335
public void UpdateConfig(SnmpConfig config)
@@ -43,7 +45,9 @@ public Task RunAsync(CancellationToken ct) =>
4345

4446
/// <summary>
4547
/// Handles the server's on-demand "Test OID" request: GET the single OID once against a
46-
/// site-local device and return the raw value (correlated by request id).
48+
/// site-local device and return the raw value (correlated by request id). The reply rides
49+
/// the result buffer like everything else; if the tunnel drops first, the server has
50+
/// already timed the request out and ignores the stale request id on replay.
4751
/// </summary>
4852
public async Task HandleOidQueryAsync(SnmpOidQuery query, CancellationToken ct)
4953
{
@@ -74,8 +78,7 @@ public async Task HandleOidQueryAsync(SnmpOidQuery query, CancellationToken ct)
7478
}
7579

7680
Console.WriteLine($"OID test request {query.RequestId}: success={result.Success} value={result.Value} error={result.Error}");
77-
try { await _tunnel.SendAsync(new AgentMessage { SnmpOidResult = result }, ct); }
78-
catch (Exception ex) { Console.Error.WriteLine($"Failed to send OID test result {query.RequestId}: {ex.Message}"); }
81+
_send(new AgentMessage { SnmpOidResult = result });
7982
}
8083

8184
/// <summary>Interface counters on the fast cadence.</summary>
@@ -131,7 +134,7 @@ await ForEachDeviceAsync(config, async device =>
131134
});
132135
}
133136
if (batch.Interfaces.Count > 0)
134-
await _tunnel.SendAsync(new AgentMessage { SnmpResults = batch }, ct);
137+
_send(new AgentMessage { SnmpResults = batch });
135138
}, ct);
136139
}
137140

@@ -181,7 +184,7 @@ await ForEachDeviceAsync(config, async device =>
181184
await PollCustomOidsAsync(poller, ip, device, config, batch);
182185

183186
if (batch.Health.Count > 0 || batch.CustomOids.Count > 0)
184-
await _tunnel.SendAsync(new AgentMessage { SnmpResults = batch }, ct);
187+
_send(new AgentMessage { SnmpResults = batch });
185188
}, ct);
186189
}
187190

0 commit comments

Comments
 (0)