refactor: log silenced UI errors, fix timer leak, narrow exception catch

- EventListShell: detach the Elapsed handler before disposing the refresh timer
  (both StartTimer and Dispose) to stop a leaked subscription firing on a
  torn-down component; log the two previously-silent catches.
- Insights: log the previously-silent report-load catch.
- EventOddsParser: narrow catch(Exception) to catch(ArgumentException) so only
  the OddsRate/OddsValue/Bet guard-clause throws are swallowed.
- AnomalyEvidenceData: make the JSON DTOs init-only per the immutability convention.
- Settings: remove a dead DialogParameters block.
This commit is contained in:
2026-05-28 22:34:17 +03:00
parent f294255f10
commit 0501f9c39c
5 changed files with 47 additions and 39 deletions
@@ -111,36 +111,36 @@ public static class AnomalyEvidenceParser
private sealed class EvidenceDto private sealed class EvidenceDto
{ {
[JsonPropertyName("suspensionGapSeconds")] [JsonPropertyName("suspensionGapSeconds")]
public int SuspensionGapSeconds { get; set; } public int SuspensionGapSeconds { get; init; }
[JsonPropertyName("preSuspension")] [JsonPropertyName("preSuspension")]
public EvidenceSideDto? PreSuspension { get; set; } public EvidenceSideDto? PreSuspension { get; init; }
[JsonPropertyName("postSuspension")] [JsonPropertyName("postSuspension")]
public EvidenceSideDto? PostSuspension { get; set; } public EvidenceSideDto? PostSuspension { get; init; }
} }
private sealed class EvidenceSideDto private sealed class EvidenceSideDto
{ {
[JsonPropertyName("capturedAt")] [JsonPropertyName("capturedAt")]
public DateTimeOffset CapturedAt { get; set; } public DateTimeOffset CapturedAt { get; init; }
[JsonPropertyName("p1")] [JsonPropertyName("p1")]
public decimal? P1 { get; set; } public decimal? P1 { get; init; }
[JsonPropertyName("pDraw")] [JsonPropertyName("pDraw")]
public decimal? PDraw { get; set; } public decimal? PDraw { get; init; }
[JsonPropertyName("p2")] [JsonPropertyName("p2")]
public decimal? P2 { get; set; } public decimal? P2 { get; init; }
[JsonPropertyName("rate1")] [JsonPropertyName("rate1")]
public decimal? Rate1 { get; set; } public decimal? Rate1 { get; init; }
[JsonPropertyName("rateDraw")] [JsonPropertyName("rateDraw")]
public decimal? RateDraw { get; set; } public decimal? RateDraw { get; init; }
[JsonPropertyName("rate2")] [JsonPropertyName("rate2")]
public decimal? Rate2 { get; set; } public decimal? Rate2 { get; init; }
} }
} }
@@ -6,9 +6,8 @@ using Marathon.Domain.Entities;
using Marathon.Domain.Enums; using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects; using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration; using AngleSharpConfig = AngleSharp.Configuration;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Infrastructure.Scraping.Parsers; namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -110,7 +109,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
// no longer rescans the document with QuerySelector // no longer rescans the document with QuerySelector
// for every key — that was an O(N) cost paid 6× per // for every key — that was an O(N) cost paid 6× per
// period). // period).
var priceIndex = BuildSelectionPriceIndex(selections); var priceIndex = BuildSelectionPriceIndex(selections);
var elementIndex = BuildSelectionElementIndex(selections); var elementIndex = BuildSelectionElementIndex(selections);
var bets = new List<Bet>(); var bets = new List<Bet>();
@@ -187,19 +186,19 @@ public sealed partial class EventOddsParser : IEventOddsParser
// Try each market variant; first match wins // Try each market variant; first match wins
foreach (var market in MatchResultMarkets) foreach (var market in MatchResultMarkets)
{ {
var win1Key = $"{eventId}@{market}.1"; var win1Key = $"{eventId}@{market}.1";
var drawKey = $"{eventId}@{market}.draw"; var drawKey = $"{eventId}@{market}.draw";
var win2Key = $"{eventId}@{market}.3"; var win2Key = $"{eventId}@{market}.3";
// Basketball 2-way OT market uses HB_H / HB_A // Basketball 2-way OT market uses HB_H / HB_A
var hbhKey = $"{eventId}@{market}.HB_H"; var hbhKey = $"{eventId}@{market}.HB_H";
var hbaKey = $"{eventId}@{market}.HB_A"; var hbaKey = $"{eventId}@{market}.HB_A";
var hasWin1 = idx.TryGetValue(win1Key, out var rate1); var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw); var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
var hasWin2 = idx.TryGetValue(win2Key, out var rate2); var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh); var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
var hasHba = idx.TryGetValue(hbaKey, out var rateHba); var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba) if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
{ {
@@ -517,8 +516,11 @@ public sealed partial class EventOddsParser : IEventOddsParser
value.HasValue ? new OddsValue(value.Value) : null, value.HasValue ? new OddsValue(value.Value) : null,
new OddsRate(rate))); new OddsRate(rate)));
} }
catch (Exception ex) catch (ArgumentException ex)
{ {
// OddsValue / OddsRate / Bet guard clauses throw ArgumentException and its
// derivatives (ArgumentNullException, ArgumentOutOfRangeException). Catch
// only those — anything else is a real bug that must not be swallowed here.
_logger.LogDebug(ex, _logger.LogDebug(ex,
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.", "Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
type, side, value, rate); type, side, value, rate);
@@ -15,6 +15,7 @@
@inject IStringLocalizer<SharedResource> L @inject IStringLocalizer<SharedResource> L
@inject IAnomalyInsightsService InsightsService @inject IAnomalyInsightsService InsightsService
@inject NavigationManager Nav @inject NavigationManager Nav
@inject ILogger<Insights> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle> <PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle>
@@ -658,8 +659,9 @@
_vm = report; _vm = report;
} }
catch (OperationCanceledException) { /* superseded */ } catch (OperationCanceledException) { /* superseded */ }
catch catch (Exception ex)
{ {
Logger.LogError(ex, "Insights: failed to build the anomaly outcome report.");
_errored = true; _errored = true;
_vm = null; _vm = null;
} }
-7
View File
@@ -282,13 +282,6 @@
private async Task<bool> ConfirmAsync() private async Task<bool> ConfirmAsync()
{ {
var parameters = new DialogParameters
{
["ContentText"] = L["Settings.Confirm.Body"].Value,
["ButtonText"] = L["Settings.Action.Save"].Value,
["CancelText"] = L["Common.Cancel"].Value,
};
var result = await Dialogs.ShowMessageBox( var result = await Dialogs.ShowMessageBox(
title: L["Settings.Confirm.Title"], title: L["Settings.Confirm.Title"],
message: L["Settings.Confirm.Body"], message: L["Settings.Confirm.Body"],
@@ -14,6 +14,7 @@
@using DomainEventId = Marathon.Domain.ValueObjects.EventId @using DomainEventId = Marathon.Domain.ValueObjects.EventId
@implements IDisposable @implements IDisposable
@inject IStringLocalizer<SharedResource> L @inject IStringLocalizer<SharedResource> L
@inject ILogger<EventListShell> Logger
<section class="m-shell"> <section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;"> <header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
@@ -364,7 +365,11 @@
private void StartTimer() private void StartTimer()
{ {
_refreshTimer?.Dispose(); if (_refreshTimer is not null)
{
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
_refreshTimer.Dispose();
}
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0; var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true }; _refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
_refreshTimer.Elapsed += OnRefreshTimerElapsed; _refreshTimer.Elapsed += OnRefreshTimerElapsed;
@@ -383,10 +388,11 @@
{ {
await InvokeAsync(LoadAsync); await InvokeAsync(LoadAsync);
} }
catch catch (Exception ex)
{ {
// Swallowed — LoadAsync already handles its own errors; this catch // Last line of defense for InvokeAsync itself — LoadAsync handles its
// is the last line of defense for InvokeAsync itself. // own errors. Log rather than silently dropping the failure.
Logger.LogError(ex, "EventListShell ({Surface}): auto-refresh tick failed.", Surface);
} }
} }
@@ -414,9 +420,10 @@
{ {
// Swallow — superseded by a newer load. // Swallow — superseded by a newer load.
} }
catch catch (Exception ex)
{ {
// Hide errors from the UI; Phase 9 will add a snackbar. // Degrade gracefully (clear the rows) but record the failure for diagnosis.
Logger.LogError(ex, "EventListShell ({Surface}): failed to load event rows.", Surface);
_rows = new List<EventListItem>(); _rows = new List<EventListItem>();
} }
finally finally
@@ -523,7 +530,11 @@
public void Dispose() public void Dispose()
{ {
_refreshTimer?.Dispose(); if (_refreshTimer is not null)
{
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
_refreshTimer.Dispose();
}
_searchCts?.Cancel(); _searchCts?.Cancel();
_searchCts?.Dispose(); _searchCts?.Dispose();
_loadCts?.Cancel(); _loadCts?.Cancel();