fix(emotecache): cancel pending texture loads on plugin dispose

Plugin-scoped CancellationTokenSource fließt jetzt durch LoadAsync und
die Texture-Calls; Dispose cancelt in-flight downloads. Smoke (System-
Spam + Reload) sauber, weiter beobachten unter höherem Emote-Volumen.
This commit is contained in:
2026-05-05 08:09:53 +02:00
parent 497197eb2c
commit e7c8667497
+58 -17
View File
@@ -66,16 +66,29 @@ public static class EmoteCache
public static string[] SortedCodeArray = []; public static string[] SortedCodeArray = [];
// Plugin-scoped cancellation source for in-flight emote loads. Dispose
// cancels every running download/texture-create so the workers don't
// touch a torn-down TextureProvider on plugin reload. Replaced with a
// fresh source on the next LoadData() call so a re-enable still works.
private static CancellationTokenSource Cts = new();
internal static CancellationToken Token => Cts.Token;
public static async Task LoadData() public static async Task LoadData()
{ {
if (State is not LoadingState.Unloaded) if (State is not LoadingState.Unloaded)
return; return;
// Refresh the CTS in case Dispose was called and we're being re-enabled
// in the same process (Dalamud /xlplugins toggle).
if (Cts.IsCancellationRequested)
Cts = new CancellationTokenSource();
State = LoadingState.Loading; State = LoadingState.Loading;
var ct = Cts.Token;
try try
{ {
var global = await Client.GetAsync(GlobalEmotes); var global = await Client.GetAsync(GlobalEmotes, ct);
var globalList = await global.Content.ReadAsStringAsync(); var globalList = await global.Content.ReadAsStringAsync(ct);
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!) foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code)) if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
@@ -84,8 +97,8 @@ public static class EmoteCache
var lastId = string.Empty; var lastId = string.Empty;
for (var i = 0; i < 15; i++) for (var i = 0; i < 15; i++)
{ {
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId)); var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
var topList = await top.Content.ReadAsStringAsync(); var topList = await top.Content.ReadAsStringAsync(ct);
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!; var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
// BetterTTV occasionally returns entries with a null Code; the // BetterTTV occasionally returns entries with a null Code; the
@@ -103,6 +116,12 @@ public static class EmoteCache
SortedCodeArray = Cache.Keys.Order().ToArray(); SortedCodeArray = Cache.Keys.Order().ToArray();
State = LoadingState.Done; State = LoadingState.Done;
} }
catch (OperationCanceledException)
{
// Plugin disposed while the cache was loading; leave State on
// Loading so a subsequent re-enable can re-issue LoadData with
// a fresh CTS (handled above).
}
catch (Exception ex) catch (Exception ex)
{ {
// Reset to Unloaded so a later trigger (e.g. the user reopening // Reset to Unloaded so a later trigger (e.g. the user reopening
@@ -116,6 +135,10 @@ public static class EmoteCache
public static void Dispose() public static void Dispose()
{ {
// Cancel in-flight downloads / texture creates so the async-void
// Load methods bail out before they touch a disposed TextureProvider.
Cts.Cancel();
foreach (var emote in EmoteImages.Values) foreach (var emote in EmoteImages.Values)
emote.InnerDispose(); emote.InnerDispose();
} }
@@ -171,7 +194,7 @@ public static class EmoteCache
ImGui.Image(Texture!.Handle, size); ImGui.Image(Texture!.Handle, size);
} }
internal async Task<byte[]> LoadAsync(Emote emote) internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
{ {
// BetterTTV-supplied Id and ImageType are interpolated straight // BetterTTV-supplied Id and ImageType are interpolated straight
// into the filename. HTTPS protects the wire, but a compromised // into the filename. HTTPS protects the wire, but a compromised
@@ -188,15 +211,15 @@ public static class EmoteCache
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
RawData = await File.ReadAllBytesAsync(filePath); RawData = await File.ReadAllBytesAsync(filePath, ct);
} }
else else
{ {
var content = await Client.GetAsync(EmotePath.Format(emote.Id)); var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
RawData = await content.Content.ReadAsByteArrayAsync(); RawData = await content.Content.ReadAsByteArrayAsync(ct);
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
stream.Write(RawData, 0, RawData.Length); await stream.WriteAsync(RawData, ct);
} }
return RawData; return RawData;
@@ -209,21 +232,28 @@ public static class EmoteCache
{ {
public ImGuiEmote Prepare(Emote emote) public ImGuiEmote Prepare(Emote emote)
{ {
Task.Run(() => Load(emote)); var ct = EmoteCache.Token;
Task.Run(() => Load(emote, ct), ct);
return this; return this;
} }
private async void Load(Emote emote) private async void Load(Emote emote, CancellationToken ct)
{ {
try try
{ {
var image = await LoadAsync(emote); var image = await LoadAsync(emote, ct);
if (image.Length <= 0) if (image.Length <= 0)
return; return;
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image); ct.ThrowIfCancellationRequested();
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image, cancellationToken: ct);
IsLoaded = true; IsLoaded = true;
} }
catch (OperationCanceledException)
{
// Plugin disposed mid-load; the EmoteImages entry is also
// being torn down, no extra cleanup needed.
}
catch (Exception ex) catch (Exception ex)
{ {
Failed = true; Failed = true;
@@ -279,15 +309,16 @@ public static class EmoteCache
public ImGuiGif Prepare(Emote emote) public ImGuiGif Prepare(Emote emote)
{ {
Task.Run(() => Load(emote)); var ct = EmoteCache.Token;
Task.Run(() => Load(emote, ct), ct);
return this; return this;
} }
private async void Load(Emote emote) private async void Load(Emote emote, CancellationToken ct)
{ {
try try
{ {
var image = await LoadAsync(emote); var image = await LoadAsync(emote, ct);
if (image.Length <= 0) if (image.Length <= 0)
return; return;
@@ -299,6 +330,8 @@ public static class EmoteCache
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>(); var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
foreach (var frame in img.Frames) foreach (var frame in img.Frames)
{ {
ct.ThrowIfCancellationRequested();
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s // Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
@@ -307,13 +340,21 @@ public static class EmoteCache
var buffer = new byte[4 * frame.Width * frame.Height]; var buffer = new byte[4 * frame.Width * frame.Height];
frame.CopyPixelDataTo(buffer); frame.CopyPixelDataTo(buffer);
var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer); var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer, cancellationToken: ct);
frames.Add((tex, delay)); frames.Add((tex, delay));
} }
Frames = frames; Frames = frames;
IsLoaded = true; IsLoaded = true;
} }
catch (OperationCanceledException)
{
// Plugin disposed mid-load; partial frames are released by
// InnerDispose on the next dispose pass.
foreach (var f in Frames)
f.Texture.Dispose();
Frames = [];
}
catch (Exception ex) catch (Exception ex)
{ {
Failed = true; Failed = true;