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
4 changes: 2 additions & 2 deletions deploy/portainer-stack.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/Beacon.Plugin/Beacon.Plugin.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<!-- Drives the built DLL, the generated manifest, and the folder Dalamud loads it from. -->
<AssemblyName>Beacon</AssemblyName>
<RootNamespace>Beacon</RootNamespace>
<Version>0.1.13.0</Version>
<Version>0.1.14.0</Version>
<!-- The Dalamud SDK does not turn these on, and every service file wants System.Threading.Tasks. -->
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
Expand Down
56 changes: 51 additions & 5 deletions src/Beacon.Plugin/Services/ImageCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}

/// <summary>
/// True when an image has failed enough times to say so out loud rather than claim it is loading.
/// </summary>
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)
Expand Down Expand Up @@ -164,8 +190,28 @@ private sealed class Entry : IDisposable

public bool Failed { get; set; }

/// <summary>Set while a fetch is in flight, so a retry cannot start a second one.</summary>
public bool Loading { get; set; }

/// <summary>How many times this image has failed in a row. Reset by a success.</summary>
public int Attempts { get; set; }

/// <summary>The earliest a failed entry may be tried again.</summary>
public DateTime RetryAt { get; set; }

public bool Disposed { get; private set; }

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
16 changes: 10 additions & 6 deletions src/Beacon.Plugin/UI/ProfileCard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions tools/contrast-audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading