diff --git a/RELEASING.md b/RELEASING.md
index 38bf96a..e494779 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -110,3 +110,57 @@ The workflow automatically:
| Store submission rejected | Check Partner Center dashboard for validation errors |
| WinGet submission fails | Verify `WINGET_TOKEN` secret is set and not expired. For the first release, manually submit using `wingetcreate new` |
| Build fails | The release workflow uses the same build process as CI — check for build errors in the Actions log |
+
+## Graceful Shutdown & Uninstall
+
+### Architecture
+
+The extension now supports graceful shutdown during uninstall without requiring Command Palette to close. This is implemented via:
+
+- **ShutdownCoordinator**: Listens on a named pipe (`\\.\pipe\WeatherExtension-Shutdown`) for external shutdown signals, with a fallback named event (`WeatherExtension-Shutdown`)
+- **Program.cs**: Uses `WaitHandle.WaitAny()` to coordinate between normal Command Palette shutdown and external uninstall signals
+- **Watchdog Timer**: A 5-second timeout ensures the process exits cleanly even if disposal hangs
+
+### How Uninstall Works
+
+1. Windows initiates uninstall
+2. Uninstall script or tool sends shutdown signal via the named pipe
+3. Extension process receives signal and gracefully disposes all resources
+4. Process exits cleanly, allowing Windows to remove the package
+5. No manual restart of Command Palette required
+
+### Signaling Shutdown Externally (PowerShell Example)
+
+```powershell
+# Signal graceful shutdown via named pipe
+$pipeName = "WeatherExtension-Shutdown"
+$pipeClient = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, "Out")
+try {
+ $pipeClient.Connect(1000)
+ $sw = New-Object System.IO.StreamWriter($pipeClient)
+ $sw.WriteLine("SHUTDOWN")
+ $sw.Flush()
+} catch {
+ Write-Host "Could not signal shutdown: $_"
+} finally {
+ $pipeClient.Close()
+}
+```
+
+Alternatively, use the named event for simpler but less robust signaling:
+
+```powershell
+# Signal shutdown via named event
+[System.Threading.EventWaitHandle]::new($false, "Global", "WeatherExtension-Shutdown").Set()
+```
+
+### Resource Cleanup During Shutdown
+
+The extension ensures all resources are properly disposed during shutdown:
+- Timers are stopped
+- HTTP client connections are closed
+- Cancellation tokens are signalled
+- Event handler subscriptions are unsubscribed
+- No dangling handles or resources remain
+
+See `ShutdownCoordinator.cs` and disposal methods for implementation details.
diff --git a/WeatherExtension.Tests/ResxCompletenessTests.cs b/WeatherExtension.Tests/ResxCompletenessTests.cs
index 32f5fc1..d891d49 100644
--- a/WeatherExtension.Tests/ResxCompletenessTests.cs
+++ b/WeatherExtension.Tests/ResxCompletenessTests.cs
@@ -18,14 +18,32 @@ namespace Microsoft.CmdPal.Ext.Weather.UnitTests;
[TestClass]
public class ResxCompletenessTests
{
- // Resolve the Properties directory relative to the test output directory.
- // Test output: WeatherExtension.Tests/bin/Debug/net8.0-windows10.0.22621.0/
- // Target: WeatherExtension/Properties/
- private static readonly string PropertiesDir = Path.GetFullPath(
- Path.Combine(AppContext.BaseDirectory, "../../../..", "WeatherExtension", "Properties"));
-
+ private static readonly string PropertiesDir = FindPropertiesDirectory();
private static readonly string BaselineFile = Path.Combine(PropertiesDir, "Resources.resx");
+ ///
+ /// Walks up from AppContext.BaseDirectory until it finds WeatherExtension/Properties.
+ /// This is more robust than a fixed relative path depth.
+ ///
+ private static string FindPropertiesDirectory()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (current != null)
+ {
+ var candidate = Path.Combine(current.FullName, "WeatherExtension", "Properties");
+ if (Directory.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException(
+ $"Could not find WeatherExtension/Properties directory by walking up from {AppContext.BaseDirectory}");
+ }
+
private static IReadOnlyList ReadKeys(string resxPath)
{
var doc = XDocument.Load(resxPath);
diff --git a/WeatherExtension/Program.cs b/WeatherExtension/Program.cs
index 1cd5c15..7463c32 100644
--- a/WeatherExtension/Program.cs
+++ b/WeatherExtension/Program.cs
@@ -6,6 +6,7 @@
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
using System.Threading;
+using BaldBeardedBuilder.WeatherExtension;
namespace WeatherExtension;
@@ -19,19 +20,70 @@ public static void Main(string[] args)
global::Shmuelie.WinRTServer.ComServer server = new();
ManualResetEvent extensionDisposedEvent = new(false);
+ ShutdownCoordinator? coordinator = null;
- // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
- // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
- // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
- WeatherExtension extensionInstance = new(extensionDisposedEvent);
- server.RegisterClass(() => extensionInstance);
- server.Start();
-
- // This will make the main thread wait until the event is signalled by the extension class.
- // Since we have single instance of the extension object, we exit as soon as it is disposed.
- extensionDisposedEvent.WaitOne();
- server.Stop();
- server.UnsafeDispose();
+ try
+ {
+ // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
+ // This makes sure that only one instance of WeatherExtension is alive, which is returned every time the host asks for the IExtension object.
+ // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
+ WeatherExtension extensionInstance = new(extensionDisposedEvent);
+ server.RegisterClass(() => extensionInstance);
+
+ // Set up graceful shutdown signaling from external processes (e.g., uninstaller)
+ coordinator = new ShutdownCoordinator();
+
+ server.Start();
+
+ // Wait for either normal disposal (Command Palette shutdown) or external shutdown signal (uninstall)
+ WaitHandle[] waitHandles = [extensionDisposedEvent, coordinator.ShutdownHandle];
+ int signalledIndex = WaitHandle.WaitAny(waitHandles);
+
+ // Log which shutdown path was taken
+ if (signalledIndex == 1)
+ {
+ WeatherLogger.LogToHost(
+ MessageState.Info,
+ "External shutdown signal received; initiating graceful shutdown");
+
+ // If external shutdown signal fired (not normal disposal),
+ // manually trigger disposal to clean up resources
+ extensionInstance.Dispose();
+ }
+
+ // Set up watchdog timer: if shutdown takes too long, force exit
+ using var watchdog = new Timer(
+ _ =>
+ {
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ "Shutdown watchdog timeout; forcing process exit");
+ Environment.Exit(1);
+ },
+ state: null,
+ dueTime: TimeSpan.FromSeconds(5),
+ period: Timeout.InfiniteTimeSpan);
+
+ // Wait for extension disposal to complete
+ extensionDisposedEvent.WaitOne();
+
+ // Cancel the watchdog timer since shutdown completed in time
+ watchdog.Change(Timeout.Infinite, Timeout.Infinite);
+
+ server.Stop();
+ }
+ catch (Exception ex)
+ {
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"Fatal error during shutdown coordination: {ex.Message}");
+ }
+ finally
+ {
+ coordinator?.Dispose();
+ extensionDisposedEvent.Dispose();
+ server?.UnsafeDispose();
+ }
}
else
{
diff --git a/WeatherExtension/Properties/Resources.ar.resx b/WeatherExtension/Properties/Resources.ar.resx
index 9ee2196..7004633 100644
--- a/WeatherExtension/Properties/Resources.ar.resx
+++ b/WeatherExtension/Properties/Resources.ar.resx
@@ -120,4 +120,16 @@
ج.غ
غ
ش.غ
+ إرسال تقرير عن خطأ
+ حفظ السجلات على سطح المكتب
+ فتح GitHub Issues
+ ## كيفية الإبلاغ عن خطأ
+
+1. انقر على **حفظ السجلات على سطح المكتب** لجمع سجلات التشخيص
+2. انقر على **فتح GitHub Issues** لإنشاء مشكلة جديدة
+3. صِف المشكلة التي واجهتها
+4. اسحب ملف ZIP وأفلته في نص المشكلة
+
+سيتم حفظ ملف ZIP على سطح المكتب.
+ تم حفظ السجلات على سطح المكتب
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.de.resx b/WeatherExtension/Properties/Resources.de.resx
index 3778e3c..cd50ead 100644
--- a/WeatherExtension/Properties/Resources.de.resx
+++ b/WeatherExtension/Properties/Resources.de.resx
@@ -120,4 +120,16 @@ Wien, ÖsterreichSW
W
NW
+ Fehlerbericht einreichen
+ Protokolle auf Desktop speichern
+ GitHub Issues öffnen
+ ## So melden Sie einen Fehler
+
+1. Klicken Sie unten auf **Protokolle auf Desktop speichern**, um Diagnoseprotokolle zu sammeln
+2. Klicken Sie auf **GitHub Issues öffnen**, um ein neues Issue zu erstellen
+3. Beschreiben Sie das aufgetretene Problem
+4. Ziehen Sie die ZIP-Datei in den Issue-Text
+
+Die ZIP-Datei wird auf Ihrem Desktop gespeichert.
+ Protokolle auf Desktop gespeichert
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.es.resx b/WeatherExtension/Properties/Resources.es.resx
index 878b4ec..95db3de 100644
--- a/WeatherExtension/Properties/Resources.es.resx
+++ b/WeatherExtension/Properties/Resources.es.resx
@@ -120,4 +120,16 @@ Buenos Aires, ArgentinaSO
O
NO
+ Enviar un informe de error
+ Guardar registros en el escritorio
+ Abrir GitHub Issues
+ ## Cómo informar de un error
+
+1. Haz clic en **Guardar registros en el escritorio** para recopilar registros de diagnóstico
+2. Haz clic en **Abrir GitHub Issues** para crear un nuevo issue
+3. Describe el problema que encontraste
+4. Arrastra y suelta el archivo ZIP en el cuerpo del issue
+
+El archivo ZIP se guardará en tu escritorio.
+ Registros guardados en el escritorio
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.fr.resx b/WeatherExtension/Properties/Resources.fr.resx
index 1212b1d..10450d0 100644
--- a/WeatherExtension/Properties/Resources.fr.resx
+++ b/WeatherExtension/Properties/Resources.fr.resx
@@ -120,4 +120,16 @@ Bruxelles, BelgiqueSO
O
NO
+ Soumettre un rapport de bug
+ Enregistrer les journaux sur le bureau
+ Ouvrir GitHub Issues
+ ## Comment signaler un bug
+
+1. Cliquez sur **Enregistrer les journaux sur le bureau** pour collecter les journaux de diagnostic
+2. Cliquez sur **Ouvrir GitHub Issues** pour créer un nouveau ticket
+3. Décrivez le problème rencontré
+4. Glissez-déposez le fichier ZIP dans le corps du ticket
+
+Le fichier ZIP sera enregistré sur votre bureau.
+ Journaux enregistrés sur le bureau
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.it.resx b/WeatherExtension/Properties/Resources.it.resx
index 19ae1f8..99f744d 100644
--- a/WeatherExtension/Properties/Resources.it.resx
+++ b/WeatherExtension/Properties/Resources.it.resx
@@ -120,4 +120,16 @@ Lugano, SvizzeraSO
O
NO
+ Invia una segnalazione di bug
+ Salva log sul Desktop
+ Apri GitHub Issues
+ ## Come segnalare un bug
+
+1. Fai clic su **Salva log sul Desktop** per raccogliere i log di diagnostica
+2. Fai clic su **Apri GitHub Issues** per creare una nuova segnalazione
+3. Descrivi il problema riscontrato
+4. Trascina il file ZIP nel corpo della segnalazione
+
+Il file ZIP verrà salvato sul Desktop.
+ Log salvati sul Desktop
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.ja.resx b/WeatherExtension/Properties/Resources.ja.resx
index 731de91..9db90d5 100644
--- a/WeatherExtension/Properties/Resources.ja.resx
+++ b/WeatherExtension/Properties/Resources.ja.resx
@@ -120,4 +120,16 @@
å—西
西
北西
+ バグを報告する
+ ログをデスクトップに保存
+ GitHub Issues を開く
+ ## バグの報告方法
+
+1. 下の **ログをデスクトップに保存** をクリックして診断ログを収集します
+2. **GitHub Issues を開く** をクリックして新しい Issue を作成します
+3. 発生した問題を説明してください
+4. ZIP ファイルを Issue 本文にドラッグ アンド ドロップしてください
+
+ZIP ファイルはデスクトップに保存されます。
+ ログをデスクトップに保存しました
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.ko.resx b/WeatherExtension/Properties/Resources.ko.resx
index 27e6aab..d3131b0 100644
--- a/WeatherExtension/Properties/Resources.ko.resx
+++ b/WeatherExtension/Properties/Resources.ko.resx
@@ -120,4 +120,16 @@
남서
서
ë¶ì„œ
+ 버그 신고
+ 로그를 바탕화면에 저장
+ GitHub Issues 열기
+ ## 버그 신고 방법
+
+1. 아래의 **로그를 바탕화면에 저장**을 클릭하여 진단 로그를 수집합니다
+2. **GitHub Issues 열기**를 클릭하여 새 Issue를 생성합니다
+3. 발생한 문제를 설명합니다
+4. ZIP 파일을 Issue 본문에 끌어다 놓습니다
+
+ZIP 파일이 바탕화면에 저장됩니다.
+ 로그가 바탕화면에 저장됨
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.nl.resx b/WeatherExtension/Properties/Resources.nl.resx
index a24033f..61ed917 100644
--- a/WeatherExtension/Properties/Resources.nl.resx
+++ b/WeatherExtension/Properties/Resources.nl.resx
@@ -120,4 +120,16 @@ Brussel, BelgiëZW
W
NW
+ Een bugrapport indienen
+ Logbestanden opslaan op bureaublad
+ GitHub Issues openen
+ ## Hoe een bug te melden
+
+1. Klik hieronder op **Logbestanden opslaan op bureaublad** om diagnoselogbestanden te verzamelen
+2. Klik op **GitHub Issues openen** om een nieuw issue aan te maken
+3. Beschrijf het probleem dat u bent tegengekomen
+4. Sleep het ZIP-bestand naar de body van het issue
+
+Het ZIP-bestand wordt op uw bureaublad opgeslagen.
+ Logbestanden opgeslagen op bureaublad
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.pl.resx b/WeatherExtension/Properties/Resources.pl.resx
index 7183d01..af2fa49 100644
--- a/WeatherExtension/Properties/Resources.pl.resx
+++ b/WeatherExtension/Properties/Resources.pl.resx
@@ -120,4 +120,16 @@ Berlin, NiemcySW
W
NW
+ Zgłoś błąd
+ Zapisz logi na pulpicie
+ Otwórz GitHub Issues
+ ## Jak zgłosić błąd
+
+1. Kliknij poniżej **Zapisz logi na pulpicie**, aby zebrać logi diagnostyczne
+2. Kliknij **Otwórz GitHub Issues**, aby utworzyć nowe zgłoszenie
+3. Opisz napotkany problem
+4. Przeciągnij i upuść plik ZIP do treści zgłoszenia
+
+Plik ZIP zostanie zapisany na pulpicie.
+ Logi zapisano na pulpicie
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.pt-BR.resx b/WeatherExtension/Properties/Resources.pt-BR.resx
index 47749b2..d543b07 100644
--- a/WeatherExtension/Properties/Resources.pt-BR.resx
+++ b/WeatherExtension/Properties/Resources.pt-BR.resx
@@ -120,4 +120,16 @@ Lisboa, PortugalSO
O
NO
+ Enviar um relatório de bug
+ Salvar logs na área de trabalho
+ Abrir GitHub Issues
+ ## Como reportar um bug
+
+1. Clique em **Salvar logs na área de trabalho** para coletar os logs de diagnóstico
+2. Clique em **Abrir GitHub Issues** para criar uma nova issue
+3. Descreva o problema encontrado
+4. Arraste e solte o arquivo ZIP no corpo da issue
+
+O arquivo ZIP será salvo na sua área de trabalho.
+ Logs salvos na área de trabalho
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.ru.resx b/WeatherExtension/Properties/Resources.ru.resx
index fb4be3d..5bc5d4d 100644
--- a/WeatherExtension/Properties/Resources.ru.resx
+++ b/WeatherExtension/Properties/Resources.ru.resx
@@ -120,4 +120,16 @@
ЮЗ
Ğ—
СЗ
+ Отправить отчёт об ошибке
+ Сохранить журналы на рабочий стол
+ Открыть GitHub Issues
+ ## Как сообщить об ошибке
+
+1. Нажмите **Сохранить журналы на рабочий стол**, чтобы собрать диагностические журналы
+2. Нажмите **Открыть GitHub Issues**, чтобы создать новый тикет
+3. Опишите возникшую проблему
+4. Перетащите ZIP-файл в текст тикета
+
+ZIP-файл будет сохранён на рабочем столе.
+ Журналы сохранены на рабочий стол
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.tr.resx b/WeatherExtension/Properties/Resources.tr.resx
index 24e21a5..4ba6ae0 100644
--- a/WeatherExtension/Properties/Resources.tr.resx
+++ b/WeatherExtension/Properties/Resources.tr.resx
@@ -245,4 +245,16 @@ Berlin, AlmanyaGB
B
KB
+ Hata Bildirimi Gönder
+ Günlükleri Masaüstüne Kaydet
+ GitHub Issues'ı Aç
+ ## Hata nasıl bildirilir
+
+1. Tanılama günlüklerini toplamak için aşağıdaki **Günlükleri Masaüstüne Kaydet**'e tıklayın
+2. Yeni bir issue oluşturmak için **GitHub Issues'ı Aç**'a tıklayın
+3. Karşılaştığınız sorunu açıklayın
+4. ZIP dosyasını issue gövdesine sürükleyip bırakın
+
+ZIP dosyası masaüstünüze kaydedilecektir.
+ Günlükler masaüstüne kaydedildi
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.zh-Hans.resx b/WeatherExtension/Properties/Resources.zh-Hans.resx
index d4eabb7..0b52a30 100644
--- a/WeatherExtension/Properties/Resources.zh-Hans.resx
+++ b/WeatherExtension/Properties/Resources.zh-Hans.resx
@@ -120,4 +120,16 @@
西å—
西
西北
+ 提交错误报告
+ 将日志保存到桌面
+ 打开 GitHub Issues
+ ## 如何报告错误
+
+1. 点击下方的 **将日志保存到桌面** 来收集诊断日志
+2. 点击 **打开 GitHub Issues** 创建新 Issue
+3. 描述您遇到的问题
+4. 将 ZIP 文件拖放到 Issue 正文中
+
+ZIP 文件将保存到您的桌面。
+ 日志已保存到桌面
\ No newline at end of file
diff --git a/WeatherExtension/Properties/Resources.zh-Hant.resx b/WeatherExtension/Properties/Resources.zh-Hant.resx
index e77f6b5..bf88c97 100644
--- a/WeatherExtension/Properties/Resources.zh-Hant.resx
+++ b/WeatherExtension/Properties/Resources.zh-Hant.resx
@@ -120,4 +120,16 @@
西å—
西
西北
+ 提交錯誤報告
+ 將日誌儲存到桌面
+ 開啟 GitHub Issues
+ ## 如何回報錯誤
+
+1. 點擊下方的 **將日誌儲存到桌面** 來收集診斷日誌
+2. 點擊 **開啟 GitHub Issues** 建立新 Issue
+3. 描述您遇到的問題
+4. 將 ZIP 檔案拖放到 Issue 內文中
+
+ZIP 檔案將儲存到您的桌面。
+ 日誌已儲存到桌面
\ No newline at end of file
diff --git a/WeatherExtension/ShutdownCoordinator.cs b/WeatherExtension/ShutdownCoordinator.cs
new file mode 100644
index 0000000..10de002
--- /dev/null
+++ b/WeatherExtension/ShutdownCoordinator.cs
@@ -0,0 +1,175 @@
+// Copyright (c) Bald Bearded Builder LLC
+// Bald Bearded Builder LLC licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.IO.Pipes;
+using System.Threading;
+using BaldBeardedBuilder.WeatherExtension;
+using Microsoft.CommandPalette.Extensions;
+
+namespace WeatherExtension;
+
+///
+/// Coordinates graceful shutdown of the extension via named pipes or named events.
+/// Allows external processes (e.g., uninstaller) to signal shutdown without
+/// relying on Command Palette's disposal chain.
+///
+internal sealed partial class ShutdownCoordinator : IDisposable
+{
+ private readonly ManualResetEvent _shutdownSignal = new(false);
+ private readonly CancellationTokenSource _listenerCts = new();
+ private Thread? _listenerThread;
+
+ ///
+ /// Gets a WaitHandle that signals when external shutdown is requested.
+ ///
+ public WaitHandle ShutdownHandle => _shutdownSignal;
+
+ ///
+ /// Initializes the coordinator and starts listening for shutdown signals
+ /// via named pipe and named event.
+ ///
+ public ShutdownCoordinator()
+ {
+ _listenerThread = new Thread(ListenForShutdownAsync)
+ {
+ Name = "WeatherExtension-ShutdownListener",
+ IsBackground = true,
+ };
+
+ _listenerThread.Start();
+ }
+
+ private void ListenForShutdownAsync()
+ {
+ // Try the primary shutdown method: named pipe
+ if (TryNamedPipeShutdown())
+ {
+ _shutdownSignal.Set();
+ return;
+ }
+
+ // Fall back to named event if named pipe fails
+ if (TryNamedEventShutdown())
+ {
+ _shutdownSignal.Set();
+ return;
+ }
+
+ // If both fail, log the error but continue — graceful degradation
+ // allows the extension to keep running if shutdown signaling is unavailable.
+ }
+
+ ///
+ /// Attempts to listen on a named pipe for shutdown signals.
+ /// Returns true if shutdown was signaled; false if the operation failed or was cancelled.
+ ///
+ private static bool TryNamedPipeShutdown()
+ {
+ try
+ {
+ string pipeName = "WeatherExtension-Shutdown";
+
+ using var pipeServer = new NamedPipeServerStream(
+ pipeName,
+ PipeDirection.In,
+ 1,
+ PipeTransmissionMode.Message,
+ PipeOptions.None);
+
+ pipeServer.WaitForConnection();
+
+ // Any data on the pipe triggers shutdown
+ byte[] buffer = new byte[1];
+ int bytesRead = pipeServer.Read(buffer, 0, 1);
+
+ return bytesRead > 0;
+ }
+ catch (OperationCanceledException)
+ {
+ // Graceful cancellation during listener shutdown
+ return false;
+ }
+ catch (IOException ex)
+ {
+ // Pipe-related errors: already exists, access denied, etc.
+ WeatherLogger.LogToHost(
+ MessageState.Info,
+ $"Named pipe shutdown listener failed: {ex.Message}");
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ // Permissions issue
+ WeatherLogger.LogToHost(
+ MessageState.Info,
+ $"Named pipe shutdown listener denied: {ex.Message}");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ // Unexpected error
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"Unexpected error in named pipe shutdown listener: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to listen on a named event for shutdown signals.
+ /// Returns true if shutdown was signaled; false if the operation failed or was cancelled.
+ ///
+ private bool TryNamedEventShutdown()
+ {
+ try
+ {
+ string eventName = "WeatherExtension-Shutdown";
+
+ // Try to open an existing event (created by external process or previous instance)
+ // If it doesn't exist, this creates a new one with initial state "not set"
+ using var shutdownEvent = new EventWaitHandle(
+ false,
+ EventResetMode.AutoReset,
+ eventName,
+ out bool createdNew);
+
+ // If we created this event, no one will signal it from outside.
+ // Wait anyway in case another process signals it later.
+ int index = WaitHandle.WaitAny(new[] { shutdownEvent, _listenerCts.Token.WaitHandle });
+ return index == 0; // 0 means the event was signaled; 1 means cancellation
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ // Permissions issue (e.g., event exists but we can't access it)
+ WeatherLogger.LogToHost(
+ MessageState.Info,
+ $"Named event shutdown listener denied: {ex.Message}");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ // Unexpected error
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"Unexpected error in named event shutdown listener: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Stops the listener thread and releases resources.
+ ///
+ public void Dispose()
+ {
+ _listenerCts.Cancel();
+ _listenerCts.Dispose();
+
+ if (_listenerThread?.IsAlive == true)
+ {
+ _listenerThread.Join(timeout: TimeSpan.FromSeconds(2));
+ }
+
+ _shutdownSignal.Dispose();
+ }
+}
diff --git a/WeatherExtension/WeatherExtension.cs b/WeatherExtension/WeatherExtension.cs
index f68584c..898fe2d 100644
--- a/WeatherExtension/WeatherExtension.cs
+++ b/WeatherExtension/WeatherExtension.cs
@@ -4,6 +4,7 @@
using System.Runtime.InteropServices;
using System.Threading;
+using BaldBeardedBuilder.WeatherExtension;
using Microsoft.CommandPalette.Extensions;
namespace WeatherExtension;
@@ -12,8 +13,9 @@ namespace WeatherExtension;
public sealed partial class WeatherExtension : IExtension, IDisposable
{
private readonly ManualResetEvent _extensionDisposedEvent;
-
private readonly WeatherCommandsProvider _provider = new();
+ private bool _isDisposed;
+ private readonly object _disposeLock = new();
public WeatherExtension(ManualResetEvent extensionDisposedEvent)
{
@@ -29,9 +31,42 @@ public WeatherExtension(ManualResetEvent extensionDisposedEvent)
};
}
+ ///
+ /// Disposes the extension and signals completion.
+ /// Thread-safe and idempotent: can be called multiple times from different threads.
+ ///
public void Dispose()
{
- _provider?.Dispose();
- this._extensionDisposedEvent.Set();
+ lock (_disposeLock)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+ }
+
+ try
+ {
+ _provider?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"Error during provider disposal: {ex.Message}");
+ }
+
+ try
+ {
+ this._extensionDisposedEvent.Set();
+ }
+ catch (Exception ex)
+ {
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"Error setting disposal event: {ex.Message}");
+ }
}
}