From 3dce5c614d7b257262733244e1a81c2f851c2b08 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 14 Jun 2026 23:13:01 +0200 Subject: [PATCH] fix(claude): guard ClaudeConfig.notifyListeners against use-after-dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setProjectDir / refresh / ensureProbe each await disk I/O then call notifyListeners(), but — unlike load(), which already guards — they didn't re-check _disposed afterward. A project switch (or watcher refresh) racing the config's disposal fires notifyListeners() on a disposed ChangeNotifier and throws "used after disposed". Surfaced deterministically by the test deflake (pumpEventQueue drains the async that Future.delayed(Duration.zero) was masking). Add the same `if (_disposed) return;` guard to all three. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ lib/builtin/claude/src/claude_config.dart | 3 +++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14b24a7..64320654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. session persistence moved to `--resume` (D-77); the toolchain no longer probes for it or warns when it's absent, on any platform. +### Fixed + +- **`ClaudeConfig` no longer crashes on a project switch that races teardown.** + `setProjectDir` / `refresh` / `ensureProbe` now skip `notifyListeners()` if the + config was disposed during their async load (the guard `load()` already had). + ## [2.4.1] — 2026-06-12 ### Fixed diff --git a/lib/builtin/claude/src/claude_config.dart b/lib/builtin/claude/src/claude_config.dart index b0735e8a..61bf44ae 100644 --- a/lib/builtin/claude/src/claude_config.dart +++ b/lib/builtin/claude/src/claude_config.dart @@ -257,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier { if (probe == null) return; // stay on the static fallback _probe = probe; await _writeProbeCache(probe); + if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier notifyListeners(); } finally { _probing = false; @@ -268,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier { /// not re-resolved (the binary doesn't change under us at runtime). Future refresh() async { await _loadDiskConfig(); + if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier notifyListeners(); } @@ -278,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier { _stopWatching(); _projectDir = dir; await _loadDiskConfig(); + if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier _startWatchers(); notifyListeners(); }