Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
54 changes: 54 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
76 changes: 64 additions & 12 deletions WeatherExtension/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
using System.Threading;
using BaldBeardedBuilder.WeatherExtension;

namespace WeatherExtension;

Expand All @@ -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<WeatherExtension, IExtension>(() => 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 SampleExtension is alive, which is returned every time the host asks for the IExtension object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not SampleExtension

// 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<WeatherExtension, IExtension>(() => 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
{
Expand Down
175 changes: 175 additions & 0 deletions WeatherExtension/ShutdownCoordinator.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal sealed partial class ShutdownCoordinator : IDisposable
{
private readonly ManualResetEvent _shutdownSignal = new(false);
private readonly CancellationTokenSource _listenerCts = new();
private Thread? _listenerThread;

/// <summary>
/// Gets a WaitHandle that signals when external shutdown is requested.
/// </summary>
public WaitHandle ShutdownHandle => _shutdownSignal;

/// <summary>
/// Initializes the coordinator and starts listening for shutdown signals
/// via named pipe and named event.
/// </summary>
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.
}

/// <summary>
/// Attempts to listen on a named pipe for shutdown signals.
/// Returns true if shutdown was signaled; false if the operation failed or was cancelled.
/// </summary>
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;
}
}

/// <summary>
/// Attempts to listen on a named event for shutdown signals.
/// Returns true if shutdown was signaled; false if the operation failed or was cancelled.
/// </summary>
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;
}
}

/// <summary>
/// Stops the listener thread and releases resources.
/// </summary>
public void Dispose()
{
_listenerCts.Cancel();
_listenerCts.Dispose();

if (_listenerThread?.IsAlive == true)
{
_listenerThread.Join(timeout: TimeSpan.FromSeconds(2));
}

_shutdownSignal.Dispose();
}
}
41 changes: 38 additions & 3 deletions WeatherExtension/WeatherExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Runtime.InteropServices;
using System.Threading;
using BaldBeardedBuilder.WeatherExtension;
using Microsoft.CommandPalette.Extensions;

namespace WeatherExtension;
Expand All @@ -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)
{
Expand All @@ -29,9 +31,42 @@ public WeatherExtension(ManualResetEvent extensionDisposedEvent)
};
}

/// <summary>
/// Disposes the extension and signals completion.
/// Thread-safe and idempotent: can be called multiple times from different threads.
/// </summary>
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}");
}
}
}
Loading