From 0ef814bb47f8340a43dee36d31f29854f4d0dbbe Mon Sep 17 00:00:00 2001 From: Shaala <156393271+ShaalaXIV@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:21:26 -0500 Subject: [PATCH] Retry a failed image fetch instead of giving up for the session A portrait that would not stay put between restarts was never a saving problem. The picture was on the server the whole time. ImageCache set Failed on the first unsuccessful fetch and never cleared it, so an image that failed once was dead until the plugin reloaded. The commonest cause is the card being drawn before the plugin has finished connecting, or a blink of network at login -- half a second of bad luck turning into a picture that never appears again that session. Restarting the game built a new cache, which is why it looked like the photo failed to persist and then came back. Fetches now retry with a backoff of 2, 4, 8, 16, 32 then 60 seconds, and a success clears the count. Cancellation during unload is no longer recorded as a failure, since it poisons an entry for a cache that is about to be discarded. The frame also stops lying. It said "Loading..." whether a fetch was in flight or had given up entirely, which is worse than an empty frame because the reader cannot act on it. After three failures it says "Picture unavailable". Co-Authored-By: Claude Opus 5 --- deploy/portainer-stack.yml | 4 +- src/Beacon.Plugin/Beacon.Plugin.csproj | 2 +- src/Beacon.Plugin/Services/ImageCache.cs | 56 +++++++++++++++++++++--- src/Beacon.Plugin/UI/ProfileCard.cs | 16 ++++--- tools/contrast-audit.py | 2 + 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml index 4785f93..a3adeab 100644 --- a/deploy/portainer-stack.yml +++ b/deploy/portainer-stack.yml @@ -1,6 +1,6 @@ services: beacon-server: - image: beacon-server:0.1.13 + image: beacon-server:0.1.14 restart: always environment: ASPNETCORE_ENVIRONMENT: Production @@ -59,7 +59,7 @@ services: /bin/sh -c '(while sleep 86400; do nginx -s reload; done) & exec nginx -g "daemon off;"' beacon-backup: - image: beacon-backup:0.1.13 + image: beacon-backup:0.1.14 restart: always environment: BACKUP_INTERVAL_SECONDS: "86400" diff --git a/src/Beacon.Plugin/Beacon.Plugin.csproj b/src/Beacon.Plugin/Beacon.Plugin.csproj index e15bcec..8c347af 100644 --- a/src/Beacon.Plugin/Beacon.Plugin.csproj +++ b/src/Beacon.Plugin/Beacon.Plugin.csproj @@ -4,7 +4,7 @@ Beacon Beacon - 0.1.13.0 + 0.1.14.0 enable diff --git a/src/Beacon.Plugin/Services/ImageCache.cs b/src/Beacon.Plugin/Services/ImageCache.cs index 71d310c..dfeccd0 100644 --- a/src/Beacon.Plugin/Services/ImageCache.cs +++ b/src/Beacon.Plugin/Services/ImageCache.cs @@ -42,10 +42,24 @@ public sealed class ImageCache(BeaconApi api) : IDisposable if (entries.TryGetValue(key, out var existing)) { existing.LastTouched = DateTime.UtcNow; + + // A failed fetch is retried, not remembered for ever. + // + // The commonest failure by far is asking before the plugin has finished connecting, or a + // blink of network at login. Giving up permanently turned a half-second hiccup into a + // picture that never appeared again until the game was restarted -- which is exactly what + // it looked like from the outside: a portrait that would not stay saved. + if (existing.Failed && !existing.Loading && DateTime.UtcNow >= existing.RetryAt) + { + existing.Failed = false; + existing.Loading = true; + _ = LoadAsync(key, existing); + } + return existing.Texture; } - var entry = new Entry { LastTouched = DateTime.UtcNow }; + var entry = new Entry { LastTouched = DateTime.UtcNow, Loading = true }; if (!entries.TryAdd(key, entry)) return entries.TryGetValue(key, out var raced) ? raced.Texture : null; @@ -73,7 +87,7 @@ private async Task LoadAsync((Guid Id, bool Thumb) key, Entry entry) if (bytes is null || bytes.Length == 0) { - entry.Failed = true; + entry.RecordFailure(); return; } @@ -87,18 +101,30 @@ private async Task LoadAsync((Guid Id, bool Thumb) key, Entry entry) } entry.Texture = texture; + entry.Attempts = 0; } catch (OperationCanceledException) { - entry.Failed = true; + // The plugin is unloading. That is not a failure to remember, and marking it one would + // poison the entry for a cache that is about to be thrown away anyway. } catch (Exception ex) { - entry.Failed = true; - Svc.Log.Warning(ex, "Could not load beacon image {ImageId}.", key.Id); + entry.RecordFailure(); + Svc.Log.Warning(ex, "Could not load beacon image {ImageId}, attempt {Attempt}.", key.Id, entry.Attempts); + } + finally + { + entry.Loading = false; } } + /// + /// True when an image has failed enough times to say so out loud rather than claim it is loading. + /// + public bool Struggling(Guid imageId, bool thumb = true) => + entries.TryGetValue((imageId, thumb), out var entry) && entry.Attempts >= 3; + private bool SupportsWebp() { if (webpSupported is { } known) @@ -164,8 +190,28 @@ private sealed class Entry : IDisposable public bool Failed { get; set; } + /// Set while a fetch is in flight, so a retry cannot start a second one. + public bool Loading { get; set; } + + /// How many times this image has failed in a row. Reset by a success. + public int Attempts { get; set; } + + /// The earliest a failed entry may be tried again. + public DateTime RetryAt { get; set; } + public bool Disposed { get; private set; } + /// + /// Records a failure and backs off, so a genuinely missing image is not re-requested every + /// frame while a transient one still recovers within a few seconds. + /// + public void RecordFailure() + { + Failed = true; + Attempts++; + RetryAt = DateTime.UtcNow.AddSeconds(Math.Min(60d, Math.Pow(2d, Math.Min(Attempts, 6)))); + } + public IDalamudTextureWrap? Texture { get => texture; diff --git a/src/Beacon.Plugin/UI/ProfileCard.cs b/src/Beacon.Plugin/UI/ProfileCard.cs index a3dbd63..c9dd2ff 100644 --- a/src/Beacon.Plugin/UI/ProfileCard.cs +++ b/src/Beacon.Plugin/UI/ProfileCard.cs @@ -273,14 +273,18 @@ private void DrawPortrait(ProfileDto profile, float size, float scale) } else if (profile.HasPortrait) { - // Fetching. Say so, rather than showing an empty well that reads as "the picture is gone". + // Say which it is. Claiming to be loading while the fetch has actually given up is worse + // than an empty frame, because it is a lie the reader cannot act on. draw.AddRectFilled(origin, box, Theme.ParchmentShade.Packed()); - const string Fetching = "Loading..."; - var fetchingSize = ImGui.CalcTextSize(Fetching); + + var struggling = images.Struggling(profile.PortraitImageId!.Value, thumb: false); + var label = struggling ? "Picture unavailable" : "Loading..."; + var labelSize = ImGui.CalcTextSize(label); + draw.AddText( - new Vector2(origin.X + ((size - fetchingSize.X) / 2f), origin.Y + ((height - fetchingSize.Y) / 2f)), - Theme.InkFaint.Packed(), - Fetching); + new Vector2(origin.X + ((size - labelSize.X) / 2f), origin.Y + ((height - labelSize.Y) / 2f)), + (struggling ? Theme.Wax : Theme.InkFaint).Packed(), + label); } else { diff --git a/tools/contrast-audit.py b/tools/contrast-audit.py index 3bd1c88..b72becc 100644 --- a/tools/contrast-audit.py +++ b/tools/contrast-audit.py @@ -93,6 +93,8 @@ def contrast(fg: tuple[int, int, int], bg: tuple[int, int, int]) -> float: ("InkFaint", "ParchmentShade", BODY, "'last seen' on the card"), ("BrassText", "Panel", BODY, "portrait placeholder initial"), ("Verdigris", "Well", BODY, "'Loading...' in the editor's portrait well"), + ("WaxText", "Well", BODY, "'Picture unavailable' in the editor's portrait well"), + ("Wax", "ParchmentShade", BODY, "'Picture unavailable' on the card"), ("InkFaint", "ParchmentShade", BODY, "'Loading...' in the card's portrait well"), # Ornament.Tag draws an outlined chip, not a filled one: the border is Brass but the label sits # on the page behind it. Checking the label against Brass would be measuring a pairing that is