Skip to content

Commit 9c8e24d

Browse files
AlexAlves87claude
andcommitted
fix: guard tray icon liveness before applying queued update
A tray update marshalled off the UI thread can run after shutdown has disposed the tray icon. Clearing the coordinator reference alone does not stop an already-queued delegate, so the coordinator now checks a liveness callback before touching the icon, restoring the no-op behaviour the inlined code had via its null check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2caddc1 commit 9c8e24d

3 files changed

Lines changed: 34 additions & 2 deletions

File tree

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,8 @@ private void InitializeTrayIcon()
737737
_trayIcon,
738738
hasThreadAccess: () => _dispatcherQueue == null || _dispatcherQueue.HasThreadAccess,
739739
marshal: OnUiThread,
740-
captureSnapshot: CaptureTraySnapshot);
740+
captureSnapshot: CaptureTraySnapshot,
741+
isAlive: () => _trayIcon != null);
741742
_trayIcon.IsVisible = true;
742743
_trayIconCoordinator.ApplyTrayTooltip(BuildTrayTooltip());
743744
_trayIcon.Selected += OnTrayIconSelected;

src/OpenClaw.Tray.WinUI/Services/TrayIconCoordinator.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@ internal sealed class TrayIconCoordinator
1010
private readonly Func<bool> _hasThreadAccess;
1111
private readonly Action<DispatcherQueueHandler> _marshal;
1212
private readonly Func<TrayStateSnapshot> _captureSnapshot;
13+
private readonly Func<bool> _isAlive;
1314

1415
internal TrayIconCoordinator(
1516
TrayIcon trayIcon,
1617
Func<bool> hasThreadAccess,
1718
Action<DispatcherQueueHandler> marshal,
18-
Func<TrayStateSnapshot> captureSnapshot)
19+
Func<TrayStateSnapshot> captureSnapshot,
20+
Func<bool> isAlive)
1921
{
2022
_trayIcon = trayIcon;
2123
_hasThreadAccess = hasThreadAccess;
2224
_marshal = marshal;
2325
_captureSnapshot = captureSnapshot;
26+
_isAlive = isAlive;
2427
}
2528

2629
internal void UpdateTrayIcon()
@@ -31,6 +34,11 @@ internal void UpdateTrayIcon()
3134
return;
3235
}
3336

37+
// A queued update may run after shutdown has disposed the tray icon.
38+
// Bail out so we never touch a disposed instance.
39+
if (!_isAlive())
40+
return;
41+
3442
var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "openclaw.ico");
3543
var tooltip = BuildTrayTooltip();
3644

tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,29 @@ public void TrayIcon_UpdateDelegatesToCoordinator()
294294
Assert.DoesNotContain("private void ApplyTrayTooltip", source);
295295
}
296296

297+
[Fact]
298+
public void TrayCoordinator_UpdateGuardsLivenessBeforeTouchingIcon()
299+
{
300+
var source = ReadCoordinatorSource();
301+
var method = ExtractMethod(source, "UpdateTrayIcon");
302+
303+
// A queued update can run after shutdown disposes the tray icon, so the
304+
// coordinator must bail on the liveness check before it ever calls SetIcon.
305+
var guardIndex = method.IndexOf("_isAlive()", StringComparison.Ordinal);
306+
var setIconIndex = method.IndexOf("SetIcon(", StringComparison.Ordinal);
307+
308+
Assert.True(guardIndex >= 0, "UpdateTrayIcon must check the liveness guard");
309+
Assert.True(setIconIndex >= 0, "UpdateTrayIcon must still set the icon");
310+
Assert.True(guardIndex < setIconIndex, "Liveness guard must run before SetIcon");
311+
}
312+
313+
private static string ReadCoordinatorSource()
314+
{
315+
var root = TestRepositoryPaths.GetRepositoryRoot();
316+
return File.ReadAllText(Path.Combine(
317+
root, "src", "OpenClaw.Tray.WinUI", "Services", "TrayIconCoordinator.cs"));
318+
}
319+
297320
private static string ReadAppSources()
298321
{
299322
var root = TestRepositoryPaths.GetRepositoryRoot();

0 commit comments

Comments
 (0)