[CmdPal] JS/TS Extensions Phase 4: extension service, wrapper, discovery + host wiring

Adds the JSON-RPC-backed extension service that launches, supervises, and disposes JS/TS Command Palette extensions, wires host status notifications, extension discovery, and the install root under the CmdPal data folder. Hardens the service lifecycle, file watchers, node.exe resolution, and JS numeric color/int parsing; publisher falls back to the npm author.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d243b1e9-40fb-4aed-aa60-beb5e80f7d91
This commit is contained in:
Michael Jolley
2026-07-29 12:21:49 -05:00
committed by Michael Jolley
parent 8aadbe1bd0
commit f7c1a4fc93
32 changed files with 6145 additions and 170 deletions

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -23,26 +24,38 @@ namespace Microsoft.CmdPal.JsonRpc.Models;
public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposable
{
private readonly JsonRpcConnection _connection;
private readonly JsonElement _providerMetadata;
private readonly string _id;
private readonly string _displayName;
private readonly IIconInfo _icon;
private readonly string _fallbackId;
private readonly string _fallbackDisplayName;
private readonly string? _fallbackIcon;
// Guards the provider metadata, which is set once from the initialize handshake
// after the proxy is constructed (the proxy is created before the handshake so its
// notification handlers are registered in time to receive startup notifications).
private readonly Lock _metadataLock = new();
// Host status messages use the statusId minted by the client. That lets an
// update refresh the same message and lets hide target the right one.
private readonly Dictionary<string, StatusMessage> _shownStatusMessages = new();
private readonly ConcurrentDictionary<string, JSFallbackCommandItemAdapter> _fallbackAdapters = new();
// Guards _shownStatusMessages. Host status notifications and Dispose can run
// on different threads, so reads, writes, and enumeration share one gate.
private readonly object _statusLock = new();
// Guards the host reference, the shown-status bookkeeping, and the buffer of host
// actions emitted before the host is attached. Notifications an extension raises
// while it activates (during the initialize handshake) arrive before the host is
// set; they are buffered here and flushed in order once the host attaches so those
// startup logs, statuses, and clipboard requests are not lost.
private readonly Lock _hostLock = new();
private readonly List<Action<IExtensionHost>> _pendingHostActions = [];
private JsonElement _providerMetadata;
// Provider identity carried from the initialize handshake metadata. Each field
// falls back to the configured values when the handshake omits it. Guarded by _metadataLock
// because SetProviderMetadata can refresh them after the proxy is already in use.
private string _id = "unknown";
private string _displayName = string.Empty;
private IIconInfo _icon = new IconInfo(string.Empty);
private readonly object _settingsLock = new();
// Host notifications can arrive after this proxy subscribes but before
// InitializeWithHost attaches the host. Buffer them in arrival order so
// startup status and log messages are not dropped. A null buffer means the
// host is attached and notifications can run inline.
private readonly object _preInitLock = new();
private List<BufferedHostNotification>? _preInitNotifications = new();
private IExtensionHost? _host;
private ICommandSettings? _settingsCache;
private bool _settingsLoading;
@@ -59,28 +72,76 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
ArgumentException.ThrowIfNullOrEmpty(fallbackId);
ArgumentNullException.ThrowIfNull(fallbackDisplayName);
_fallbackId = fallbackId;
_fallbackDisplayName = fallbackDisplayName;
_fallbackIcon = fallbackIcon;
_providerMetadata = providerMetadata;
// Prefer the identity and icon from the initialize handshake when they are
// present. If the extension omits a field, use the package manifest value.
_id = ReadHandshakeString(providerMetadata, "id") ?? fallbackId;
_displayName = ReadHandshakeString(providerMetadata, "displayName") ?? fallbackDisplayName;
_icon = ReadHandshakeIcon(providerMetadata) ?? new IconInfo(fallbackIcon ?? string.Empty);
// Apply the identity captured at construction. The proxy is created before the
// initialize handshake completes, so this seeds id, display name, and icon from
// whatever metadata is available now (or the configured fallback); SetProviderMetadata later
// refreshes them with the real handshake values.
lock (_metadataLock)
{
ApplyProviderIdentityLocked(providerMetadata);
}
RegisterNotificationHandlers();
// Clean up any active host statuses when the extension disconnects or its process
// exits, not only when it explicitly hides them. The wrapper also disposes this
// proxy during teardown; both paths are idempotent.
_connection.Disconnected += OnConnectionDisconnected;
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public string Id => _id;
public string Id
{
get
{
lock (_metadataLock)
{
return _id;
}
}
}
public string DisplayName => _displayName;
public string DisplayName
{
get
{
lock (_metadataLock)
{
return _displayName;
}
}
}
public IIconInfo Icon => _icon;
public IIconInfo Icon
{
get
{
lock (_metadataLock)
{
return _icon;
}
}
}
// True means the provider's top-level command set is fixed. If the extension
// leaves it out of the handshake, the wire default is true.
public bool Frozen => ReadFrozen(_providerMetadata);
// Whether the provider's top-level command set is fixed. The value is carried
// from the initialize handshake metadata; the wire default is true when the
// extension does not specify it.
public bool Frozen
{
get
{
lock (_metadataLock)
{
return ReadFrozen(_providerMetadata);
}
}
}
public ICommandSettings? Settings
{
@@ -240,76 +301,181 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
{
ArgumentNullException.ThrowIfNull(host);
List<BufferedHostNotification> buffered;
lock (_preInitLock)
lock (_hostLock)
{
if (_isDisposed)
{
return;
}
_host = host;
// Swap the buffer to null under the same lock used by notification handlers.
// _host is written first so any handler that now runs inline sees the host,
// not a stale null.
buffered = _preInitNotifications ?? new List<BufferedHostNotification>();
_preInitNotifications = null;
// Deliver, in arrival order, the host actions that the extension emitted while
// it was activating (before the host was attached), such as startup logs,
// statuses, and clipboard requests. Replaying under the lock keeps this delivery
// ordered against a concurrent dispose or disconnect so a buffered show cannot
// land after teardown has already hidden everything.
foreach (var action in _pendingHostActions)
{
try
{
action(host);
}
catch (Exception ex)
{
Logger.LogWarning($"Error flushing buffered host action for {DisplayName}: {ex.Message}");
}
}
_pendingHostActions.Clear();
}
Logger.LogDebug($"JSCommandProviderProxy initialized with host for {DisplayName}");
}
// Replay startup notifications in arrival order now that the host can receive them.
foreach (var notification in buffered)
/// <summary>
/// Sets the provider metadata captured from the initialize handshake so that the
/// author-specified <see cref="Frozen"/> value and the handshake identity (id,
/// display name, and icon) flow through instead of the wire defaults. Called by the
/// extension wrapper after the handshake completes.
/// </summary>
/// <param name="providerMetadata">The provider metadata returned during initialize.</param>
internal void SetProviderMetadata(JsonElement providerMetadata)
{
lock (_metadataLock)
{
DispatchBufferedHostNotification(notification.Method, notification.Parameters);
_providerMetadata = providerMetadata;
ApplyProviderIdentityLocked(providerMetadata);
}
}
public void Dispose()
/// <summary>
/// Runs a host action now when the host is attached, or buffers it in arrival order to
/// be replayed once <see cref="InitializeWithHost"/> attaches the host. This keeps
/// notifications emitted during activation (before the host is set) from being dropped.
/// </summary>
private void RunWithHost(Action<IExtensionHost> action)
{
lock (_hostLock)
{
RunWithHostLocked(action);
}
}
/// <summary>
/// Variant of <see cref="RunWithHost"/> that requires <see cref="_hostLock"/> to already
/// be held by the caller. Status show and hide handlers call this from inside the same
/// lock acquisition that mutates <see cref="_shownStatusMessages"/> so the dictionary
/// update and the host dispatch are a single atomic, ordered step. Splitting them across
/// two lock acquisitions would let a hide dispatch overtake its pending show.
/// </summary>
private void RunWithHostLocked(Action<IExtensionHost> action)
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
var host = _host;
if (host is null)
{
_pendingHostActions.Add(action);
return;
}
// Detach this proxy's handlers so late connection notifications stop here.
// The extension service owns process teardown and protocol dispose, so this
// proxy only releases its subscriptions and host references.
// Invoke the host action while holding the lock so status show and hide calls
// run in the same order as their lock acquisition. Host status methods are
// fire-and-forget (they return an async operation immediately), so the lock is
// held only briefly. This keeps a hide from being reordered ahead of a late
// show, and, because Dispose sets _isDisposed under this same lock, keeps a show
// from resurrecting status after teardown has hidden everything.
action(host);
}
public void Dispose()
{
lock (_hostLock)
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
var host = _host;
_host = null;
_pendingHostActions.Clear();
var activeStatuses = new List<StatusMessage>(_shownStatusMessages.Values);
_shownStatusMessages.Clear();
// Hide any status still visible while holding the lock so the hide is ordered
// after every show already dispatched and cannot be overtaken by a late show.
// Once _isDisposed is set here, RunWithHost is a no-op, so no show can resurrect
// status after teardown has hidden it.
HideStatuses(host, activeStatuses);
}
_connection.Disconnected -= OnConnectionDisconnected;
// Detach every notification handler this proxy registered so late
// notifications from the connection are no longer routed here. Process
// teardown and the protocol dispose request are owned by the extension
// service, so this proxy only releases its own subscriptions and host
// references. See the W4 coordination note in the remediation report.
foreach (var method in RegisteredNotificationMethods)
{
_connection.UnregisterNotificationHandler(method);
}
// Hide any status messages still on screen. Snapshot and clear under the lock
// so a status notification racing Dispose cannot change the map during enumeration.
var host = _host;
List<StatusMessage> pendingStatuses;
lock (_statusLock)
{
pendingStatuses = new List<StatusMessage>(_shownStatusMessages.Values);
_shownStatusMessages.Clear();
}
if (host != null)
{
foreach (var status in pendingStatuses)
{
try
{
_ = host.HideStatus(status);
}
catch (Exception ex)
{
Logger.LogWarning($"Error hiding status during dispose for {DisplayName}: {ex.Message}");
}
}
}
lock (_settingsLock)
{
(_settingsCache as IDisposable)?.Dispose();
Monitor.PulseAll(_settingsLock);
}
}
_host = null;
private void OnConnectionDisconnected(object? sender, EventArgs e)
{
// The extension disconnected or its process exited. Clear any active statuses so
// they do not linger in the host UI even though no explicit hide arrived. The
// notification handlers stay registered; the connection is gone so they cannot
// fire again, and Dispose still unregisters them during full teardown.
lock (_hostLock)
{
if (_isDisposed || _shownStatusMessages.Count == 0)
{
return;
}
var host = _host;
var activeStatuses = new List<StatusMessage>(_shownStatusMessages.Values);
_shownStatusMessages.Clear();
// Hide under the lock so this teardown hide is ordered against any concurrent
// show or dispose and cannot strand or resurrect status.
HideStatuses(host, activeStatuses);
}
}
private void HideStatuses(IExtensionHost? host, List<StatusMessage> statuses)
{
if (host is null)
{
return;
}
foreach (var status in statuses)
{
try
{
_ = host.HideStatus(status);
}
catch (Exception ex)
{
Logger.LogWarning($"Error hiding status for {DisplayName}: {ex.Message}");
}
}
}
private static readonly string[] RegisteredNotificationMethods =
@@ -360,40 +526,6 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
}
}
// Buffers a host notification until InitializeWithHost attaches the host.
// The params element is cloned because the connection may recycle the source document.
private bool TryBufferUntilHostAttached(string method, JsonElement paramsElement)
{
lock (_preInitLock)
{
if (_preInitNotifications == null)
{
return false;
}
_preInitNotifications.Add(new BufferedHostNotification(method, paramsElement.Clone()));
return true;
}
}
// Runs the buffered notification now that the host is attached. The open gate
// keeps this pass from buffering the same notification again.
private void DispatchBufferedHostNotification(string method, JsonElement paramsElement)
{
switch (method)
{
case "host/showStatus":
HandleShowStatusNotification(paramsElement);
break;
case "host/hideStatus":
HandleHideStatusNotification(paramsElement);
break;
case "host/logMessage":
HandleLogMessageNotification(paramsElement);
break;
}
}
private void HandleItemsChangedNotification(JsonElement paramsElement)
{
if (_isDisposed)
@@ -429,6 +561,23 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
try
{
JSPropertyChangeRegistry.Dispatch(_connection, paramsElement);
var commandId = JSModelMapper.GetString(paramsElement, "commandId") ?? string.Empty;
if (string.IsNullOrEmpty(commandId) ||
!_fallbackAdapters.TryGetValue(commandId, out var fallbackAdapter))
{
return;
}
if (paramsElement.TryGetProperty("properties", out var propsProp) &&
propsProp.ValueKind == JsonValueKind.Object)
{
var displayTitle = JSModelMapper.GetString(propsProp, "displayTitle");
if (displayTitle != null)
{
fallbackAdapter.UpdateDisplayTitle(displayTitle);
}
}
}
catch (Exception ex)
{
@@ -443,11 +592,6 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return;
}
if (TryBufferUntilHostAttached("host/logMessage", paramsElement))
{
return;
}
try
{
var message = JSModelMapper.GetString(paramsElement, "message");
@@ -476,11 +620,8 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
break;
}
if (_host != null)
{
var logMessage = new LogMessage { Message = message, State = (MessageState)state };
_ = _host.LogMessage(logMessage);
}
var logMessage = new LogMessage { Message = message, State = (MessageState)state };
RunWithHost(host => _ = host.LogMessage(logMessage));
}
catch (Exception ex)
{
@@ -495,11 +636,6 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return;
}
if (TryBufferUntilHostAttached("host/showStatus", paramsElement))
{
return;
}
try
{
var (message, state) = ReadStatusMessage(paramsElement);
@@ -510,32 +646,29 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
var statusId = ReadStatusId(paramsElement);
var progress = ReadProgress(paramsElement);
var context = ReadStatusContext(paramsElement);
lock (_statusLock)
StatusMessage statusMessage;
lock (_hostLock)
{
if (_isDisposed)
{
return;
}
var host = _host;
if (host == null)
{
return;
}
if (!string.IsNullOrEmpty(statusId) &&
_shownStatusMessages.TryGetValue(statusId, out var existing))
{
// Same statusId again. Update the existing message instead of
// stacking another one.
// Same status shown again: refresh it in place so the host keeps a
// single message rather than stacking duplicates. The buffered or
// already-delivered ShowStatus references this same object.
existing.Message = message;
existing.State = (MessageState)state;
existing.Progress = progress;
return;
}
var statusMessage = new StatusMessage
statusMessage = new StatusMessage
{
Message = message,
State = (MessageState)state,
@@ -547,12 +680,9 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
_shownStatusMessages[statusId] = statusMessage;
}
// Keep the map update and ShowStatus under one lock. Dispose uses
// this same lock to hide tracked statuses, so it either runs before
// this show starts or after the shown status is tracked. Releasing
// the lock between those steps would let Dispose hide a status that
// was not shown yet.
_ = host.ShowStatus(statusMessage, ReadStatusContext(paramsElement));
// Dispatch inside the same lock acquisition that recorded the status so the
// show cannot be reordered behind a hide that arrives immediately after.
RunWithHostLocked(host => _ = host.ShowStatus(statusMessage, context));
}
}
catch (Exception ex)
@@ -568,33 +698,32 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return;
}
if (TryBufferUntilHostAttached("host/hideStatus", paramsElement))
{
return;
}
try
{
var host = _host;
if (host == null)
var statusId = ReadStatusId(paramsElement);
if (string.IsNullOrEmpty(statusId))
{
return;
}
var statusId = ReadStatusId(paramsElement);
StatusMessage statusMessage;
lock (_statusLock)
lock (_hostLock)
{
if (string.IsNullOrEmpty(statusId) ||
!_shownStatusMessages.TryGetValue(statusId, out statusMessage!))
if (_isDisposed)
{
return;
}
if (!_shownStatusMessages.TryGetValue(statusId, out var existing))
{
return;
}
_shownStatusMessages.Remove(statusId);
}
_ = host.HideStatus(statusMessage);
// Dispatch inside the same lock acquisition that removed the status so the
// hide observes the same ordering as the show that preceded it.
RunWithHostLocked(host => _ = host.HideStatus(existing));
}
}
catch (Exception ex)
{
@@ -626,7 +755,7 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
private static int ReadState(JsonElement paramsElement)
{
if (paramsElement.ValueKind == JsonValueKind.Object &&
JSModelMapper.TryGetProperty(paramsElement, "state", out var stateProp) &&
JSModelMapper.TryGetAnyCase(paramsElement, "state", "State", out var stateProp) &&
stateProp.ValueKind == JsonValueKind.Number)
{
return stateProp.GetInt32();
@@ -638,7 +767,7 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
private static string ReadStatusId(JsonElement paramsElement)
{
if (paramsElement.ValueKind == JsonValueKind.Object &&
JSModelMapper.TryGetProperty(paramsElement, "statusId", out var idProp) &&
JSModelMapper.TryGetAnyCase(paramsElement, "statusId", "StatusId", out var idProp) &&
idProp.ValueKind == JsonValueKind.String)
{
return idProp.GetString() ?? string.Empty;
@@ -647,8 +776,8 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return string.Empty;
}
// Turns the wire progress payload into the toolkit shape. Null means no
// progress was reported.
// Maps a status progress payload (indeterminate spinner or a percentage) onto
// a toolkit progress state. Returns null when no progress is reported.
private static IProgressState? ReadProgress(JsonElement paramsElement)
{
if (paramsElement.ValueKind != JsonValueKind.Object ||
@@ -658,24 +787,36 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return null;
}
var progress = new ProgressState();
var isIndeterminate =
JSModelMapper.TryGetProperty(progressProp, "isIndeterminate", out var indeterminateProp) &&
indeterminateProp.ValueKind == JsonValueKind.True;
if (JSModelMapper.TryGetProperty(progressProp, "isIndeterminate", out var indeterminateProp))
{
progress.IsIndeterminate = indeterminateProp.ValueKind == JsonValueKind.True;
}
var progress = new ProgressState { IsIndeterminate = isIndeterminate };
if (JSModelMapper.TryGetProperty(progressProp, "progressPercent", out var percentProp) &&
percentProp.ValueKind == JsonValueKind.Number &&
percentProp.TryGetUInt32(out var percent))
percentProp.TryGetDouble(out var percent) &&
percent >= 0)
{
progress.ProgressPercent = percent;
progress.ProgressPercent = percent >= uint.MaxValue ? uint.MaxValue : (uint)percent;
}
return progress;
}
// Blank or missing handshake fields use the configured fallback value.
// Caller must hold _metadataLock. Applies the provider identity (id, display name,
// and icon) from the initialize handshake metadata, falling back to configured values for
// any field the handshake omits. Called from the constructor with the metadata passed
// at construction and again from SetProviderMetadata when the real handshake completes.
private void ApplyProviderIdentityLocked(JsonElement metadata)
{
_id = ReadHandshakeString(metadata, "id") ?? _fallbackId;
_displayName = ReadHandshakeString(metadata, "displayName") ?? _fallbackDisplayName;
_icon = ReadHandshakeIcon(metadata) ?? new IconInfo(_fallbackIcon ?? string.Empty);
}
// Reads a string field declared in the initialize handshake metadata. Returns null when absent or empty so the caller
// falls back to the manifest value rather than overwriting it with an empty string.
private static string? ReadHandshakeString(JsonElement metadata, string name)
{
if (metadata.ValueKind == JsonValueKind.Object &&
@@ -689,7 +830,9 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
return null;
}
// Missing handshake icons fall back to the manifest icon, not an empty glyph.
// Reads the icon declared in the initialize handshake metadata. Returns null
// when the handshake omits an icon so the caller falls back to the manifest
// icon rather than replacing it with an empty glyph.
private static IIconInfo? ReadHandshakeIcon(JsonElement metadata)
{
if (metadata.ValueKind == JsonValueKind.Object &&
@@ -717,7 +860,7 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
}
}
// The wire default is frozen when the extension leaves the flag out.
// The wire default when the extension omits the flag is frozen.
return true;
}
@@ -796,12 +939,14 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposa
{
var adapter = new JSFallbackCommandItemAdapter(element, _connection);
items.Add(adapter);
var id = adapter.Id;
if (!string.IsNullOrEmpty(id))
{
_fallbackAdapters[id] = adapter;
}
}
return items.ToArray();
}
// An arrival-ordered snapshot of a host notification that reached this proxy
// before the host was attached, held until InitializeWithHost replays it.
private readonly record struct BufferedHostNotification(string Method, JsonElement Parameters);
}

View File

@@ -22,6 +22,18 @@ internal sealed partial class JSListItemAdapter : JSObservableProxyBase, IListIt
private readonly JSLazyCache<ICommand?> _command;
private readonly JSLazyCache<IContextItem[]> _moreCommands;
private readonly JSLazyCache<IDetails?> _details;
private static readonly string[] RefreshableProperties =
[
"command",
"moreCommands",
"icon",
"title",
"subtitle",
"tags",
"details",
"section",
"textToSuggest",
];
public JSListItemAdapter(JsonElement data, JsonRpcConnection connection)
: base(GetNotificationId(data), connection, data)
@@ -83,6 +95,31 @@ internal sealed partial class JSListItemAdapter : JSObservableProxyBase, IListIt
}
}
internal static string ComputeKey(JsonElement data)
{
var id = JSModelMapper.GetString(data, "id");
if (!string.IsNullOrEmpty(id))
{
return "id:" + id;
}
if (JSModelMapper.TryGetCommandData(data, out var commandData))
{
var commandId = JSModelMapper.GetString(commandData, "id");
if (!string.IsNullOrEmpty(commandId))
{
return "cmd:" + commandId;
}
}
return "title:" + (JSModelMapper.GetString(data, "title") ?? string.Empty);
}
internal void UpdateData(JsonElement data)
{
ReplaceData(data, RefreshableProperties);
}
public override void Dispose()
{
_moreCommands.Dispose();

View File

@@ -36,9 +36,17 @@ internal sealed partial class JSListPageProxy : JSObservableProxyBase, IListPage
private readonly object _stateLock = new();
private readonly JSLazyCache<IFilters?> _filters;
private readonly JSLazyCache<ICommandItem?> _emptyContent;
private readonly object _itemCacheLock = new();
private bool? _hasMoreItemsState;
private bool _disposed;
// Adapters from the previous GetItems call, keyed by stable identity. Reusing
// the same IListItem instance for an item that persists across a refresh lets
// the host's reference-keyed view model cache keep the existing view model,
// which preserves the current list selection when a dynamic page rebuilds its
// items. A queue per key keeps reuse stable when several items share a title.
private Dictionary<string, Queue<JSListItemAdapter>> _adapterCache = new(StringComparer.Ordinal);
public JSListPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
: base(pageId, connection, pageData)
{
@@ -288,6 +296,7 @@ internal sealed partial class JSListPageProxy : JSObservableProxyBase, IListPage
_filters.Dispose();
_emptyContent.Dispose();
ResetAdapterCache();
base.Dispose();
_registry.Pages.Unregister(_pageId, this);
@@ -386,6 +395,7 @@ internal sealed partial class JSListPageProxy : JSObservableProxyBase, IListPage
{
if (!result.HasValue)
{
ResetAdapterCache();
return [];
}
@@ -398,26 +408,76 @@ internal sealed partial class JSListPageProxy : JSObservableProxyBase, IListPage
if (arrayElement.ValueKind != JsonValueKind.Array)
{
ResetAdapterCache();
return [];
}
var items = new List<IListItem>();
foreach (var element in arrayElement.EnumerateArray())
lock (_itemCacheLock)
{
if (element.ValueKind == JsonValueKind.Object &&
JSModelMapper.GetBool(element, "_isSeparator", false))
var previousCache = _adapterCache;
var nextCache = new Dictionary<string, Queue<JSListItemAdapter>>(StringComparer.Ordinal);
foreach (var element in arrayElement.EnumerateArray())
{
items.Add(new Separator(JSModelMapper.GetString(element, "title") ?? string.Empty));
}
else
{
items.Add(new JSListItemAdapter(element, Connection));
if (element.ValueKind == JsonValueKind.Object &&
JSModelMapper.GetBool(element, "_isSeparator", false))
{
items.Add(new Separator(JSModelMapper.GetString(element, "title") ?? string.Empty));
continue;
}
var key = JSListItemAdapter.ComputeKey(element);
JSListItemAdapter adapter;
if (previousCache.TryGetValue(key, out var previousQueue) && previousQueue.Count > 0)
{
adapter = previousQueue.Dequeue();
adapter.UpdateData(element);
}
else
{
adapter = new JSListItemAdapter(element, Connection);
}
items.Add(adapter);
if (!nextCache.TryGetValue(key, out var nextQueue))
{
nextQueue = new Queue<JSListItemAdapter>();
nextCache[key] = nextQueue;
}
nextQueue.Enqueue(adapter);
}
DisposeAdapters(previousCache);
_adapterCache = nextCache;
}
return items.ToArray();
}
private void ResetAdapterCache()
{
lock (_itemCacheLock)
{
DisposeAdapters(_adapterCache);
_adapterCache = new Dictionary<string, Queue<JSListItemAdapter>>(StringComparer.Ordinal);
}
}
private static void DisposeAdapters(Dictionary<string, Queue<JSListItemAdapter>> cache)
{
foreach (var adapters in cache.Values)
{
while (adapters.TryDequeue(out var adapter))
{
adapter.Dispose();
}
}
}
private sealed class PageRegistry
{
private readonly object _subscribeLock = new();

View File

@@ -79,6 +79,24 @@ internal abstract class JSObservableProxyBase : BaseObservable, IJSPropertyChang
{
}
protected void ReplaceData(JsonElement data, IReadOnlyList<string> propertyNames)
{
var current = Data;
if (current.ValueKind != JsonValueKind.Undefined &&
string.Equals(current.GetRawText(), data.GetRawText(), StringComparison.Ordinal))
{
return;
}
Volatile.Write(ref _data, new DataBox(data));
OnPropertyChangesApplied(propertyNames);
foreach (var propertyName in propertyNames)
{
OnPropertyChanged(ToAbiPropertyName(propertyName));
}
}
public virtual void Dispose()
{
if (_disposed)

View File

@@ -125,6 +125,51 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
isValid = true;
}
/// <summary>
/// Creates a wrapper for a JavaScript extension where the <see cref="ICommandProvider"/>
/// is obtained directly over JSON-RPC (not through <see cref="IExtensionWrapper.GetExtensionObject"/>).
/// </summary>
/// <param name="extension">The JS extension wrapper managing the Node.js process.</param>
/// <param name="provider">The command provider proxy backed by the JSON-RPC connection.</param>
/// <param name="mainThread">The UI thread scheduler.</param>
public CommandProviderWrapper(IExtensionWrapper extension, ICommandProvider provider, TaskScheduler mainThread)
{
_taskScheduler = mainThread;
TopLevelPageContext = new(this, _taskScheduler);
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
_commandProvider = new(provider);
try
{
var model = _commandProvider.Unsafe!;
// Hook the extension back into us
model.InitializeWithHost(ExtensionHost);
model.ItemsChanged += CommandProvider_ItemsChanged;
Id = provider.Id;
DisplayName = provider.DisplayName;
Icon = new(provider.Icon);
Icon.InitializeProperties();
// Note: explicitly not InitializeProperties()ing the settings here. If
// we do that, then we'd regress GH #38321
Settings = new(provider.Settings, this, _taskScheduler);
isValid = true;
Logger.LogDebug($"Initialized JS extension command provider {Extension.PackageFamilyName}:{Extension.ExtensionUniqueId}");
}
catch (Exception e)
{
Logger.LogError("Failed to initialize CommandProvider for JS extension.");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
}
private ProviderSettings GetProviderSettings(SettingsModel settings)
{
if (!settings.ProviderSettings.TryGetValue(ProviderId, out var ps))

View File

@@ -0,0 +1,836 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Manages a single JavaScript/TypeScript extension running as an isolated Node.js
/// process and presents it to the CmdPal host as an <see cref="IExtensionWrapper"/>.
/// The process is spawned with stdio redirection and driven over a
/// <see cref="JsonRpcConnection"/>; the <see cref="JSCommandProviderProxy"/> forwards
/// provider calls to the extension.
/// </summary>
public sealed partial class JSExtensionWrapper : IExtensionWrapper, IDisposable
{
// Consecutive crashes above this threshold mark the extension unhealthy.
private const int MaxConsecutiveCrashes = 3;
// Default Node.js inspector port. Auto-assigned ports start at 9229 (the first
// Interlocked.Increment below yields 9229 from this seed).
private static int _nextDebugPort = 9228;
private readonly JSExtensionManifest _manifest;
private readonly string _manifestDirectory;
private readonly Lock _lock = new();
private readonly List<ProviderType> _providerTypes = [];
private Process? _nodeProcess;
private JsonRpcConnection? _connection;
private JSCommandProviderProxy? _commandProviderProxy;
private Task? _startInProgress;
private bool _isDisposed;
private bool _stopping;
private int _consecutiveCrashCount;
/// <summary>
/// Initializes a new instance of the <see cref="JSExtensionWrapper"/> class.
/// </summary>
/// <param name="manifest">The parsed and validated extension manifest.</param>
/// <param name="manifestDirectory">The directory that contains the extension's package.json.</param>
public JSExtensionWrapper(JSExtensionManifest manifest, string manifestDirectory)
{
_manifest = manifest ?? throw new ArgumentNullException(nameof(manifest));
_manifestDirectory = manifestDirectory ?? throw new ArgumentNullException(nameof(manifestDirectory));
// JS extensions currently expose a single command provider.
AddProviderType(ProviderType.Commands);
}
/// <summary>
/// Raised when the underlying Node.js process exits unexpectedly (a crash), after the
/// wrapper has torn down its process and connection handles. It is not raised for an
/// intentional stop via <see cref="SignalDispose"/>. The service uses this to remove the
/// now-dead provider and decide whether to restart or disable the extension.
/// </summary>
public event EventHandler? ProcessExited;
public string PackageDisplayName => _manifest.EffectiveDisplayName;
public string ExtensionDisplayName => _manifest.EffectiveDisplayName;
public string PackageFullName => $"js!{_manifest.Name}";
public string PackageFamilyName => $"js!{_manifest.Name}";
public string Publisher => _manifest.Publisher ?? "Unknown";
public string ExtensionClassId
{
get
{
// Derive a stable identifier from the manifest name.
if (string.IsNullOrWhiteSpace(_manifest.Name))
{
return "unknown";
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(_manifest.Name));
return $"js-{Convert.ToHexString(hash)[..32]}";
}
}
public DateTimeOffset InstalledDate
{
get
{
try
{
var manifestPath = Path.Combine(_manifestDirectory, "package.json");
if (File.Exists(manifestPath))
{
return File.GetCreationTimeUtc(manifestPath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Fall through to the default below.
}
return DateTimeOffset.UtcNow;
}
}
public PackageVersion Version
{
get
{
if (string.IsNullOrWhiteSpace(_manifest.Version))
{
return new PackageVersion { Major = 1, Minor = 0, Build = 0, Revision = 0 };
}
var parts = _manifest.Version.Split('.');
return new PackageVersion
{
Major = parts.Length > 0 && ushort.TryParse(parts[0], out var major) ? major : (ushort)1,
Minor = parts.Length > 1 && ushort.TryParse(parts[1], out var minor) ? minor : (ushort)0,
Build = parts.Length > 2 && ushort.TryParse(parts[2], out var build) ? build : (ushort)0,
Revision = 0,
};
}
}
public string ExtensionUniqueId => $"js!{_manifest.Name}";
/// <summary>
/// Gets the directory that contains the extension's package.json.
/// </summary>
internal string ManifestDirectory => _manifestDirectory;
/// <summary>
/// Gets the manifest this extension was loaded with. Used by the service to detect a
/// manifest edit during an explicit refresh and reload the extension when it changed.
/// </summary>
internal JSExtensionManifest Manifest => _manifest;
/// <summary>
/// Gets the normalized identity key for this extension, used to enforce cross-extension
/// uniqueness during discovery.
/// </summary>
internal string NameKey => _manifest.NameKey;
/// <summary>
/// Gets the number of times this extension has recorded a consecutive crash
/// without a successful start in between.
/// </summary>
internal int ConsecutiveCrashCount
{
get
{
lock (_lock)
{
return _consecutiveCrashCount;
}
}
}
/// <summary>
/// Gets a value indicating whether the extension is considered healthy. It
/// becomes unhealthy after more than <see cref="MaxConsecutiveCrashes"/>
/// consecutive crashes and stays that way until a successful start resets the counter.
/// </summary>
internal bool IsHealthy { get; private set; } = true;
/// <summary>
/// Gets the capabilities advertised by the extension in its initialize response.
/// Currently advisory: recorded for diagnostics but not used to gate behavior.
/// </summary>
internal IReadOnlyList<string> Capabilities { get; private set; } = [];
public bool IsRunning()
{
lock (_lock)
{
return IsRunningLocked();
}
}
private bool IsRunningLocked()
{
if (_nodeProcess is null || _connection is null)
{
return false;
}
try
{
return !_nodeProcess.HasExited;
}
catch (InvalidOperationException)
{
return false;
}
}
public Task StartExtensionAsync()
{
lock (_lock)
{
ObjectDisposedException.ThrowIf(_isDisposed, this);
if (IsRunningLocked())
{
return Task.CompletedTask;
}
// Single-flight: a concurrent caller (for example GetProviderAsync calling in
// right after the service starts the wrapper) joins the in-progress start
// instead of spawning a second Node process. The start body runs on the thread
// pool so no process is spawned while this lock is held; the task is cleared
// when it completes so a later restart can start again.
_startInProgress ??= Task.Run(RunStartAsync);
return _startInProgress;
}
}
private async Task RunStartAsync()
{
try
{
await StartCoreAsync().ConfigureAwait(false);
}
finally
{
lock (_lock)
{
_startInProgress = null;
}
}
}
private async Task StartCoreAsync()
{
lock (_lock)
{
// The wrapper may have been disposed, or another start may have completed,
// between scheduling this start and running it.
if (_isDisposed || IsRunningLocked())
{
return;
}
}
Logger.LogDebug($"Starting JS extension {_manifest.EffectiveDisplayName}");
var entryPoint = _manifest.EntryPointPath ?? Path.Combine(_manifestDirectory, _manifest.Main ?? string.Empty);
if (!File.Exists(entryPoint))
{
Logger.LogError($"Entry point not found for {_manifest.Name}: {entryPoint}");
return;
}
// Launch through the Phase 1 SDK bootstrap when it is installed so the bootstrap
// claims stdout for the protocol before the extension entry is dynamically imported.
// The effective launch command is:
// node [--inspect=<port>] "<bootstrap>" "<entry>"
// and, when the bootstrap cannot be resolved:
// node [--inspect=<port>] "<entry>"
var bootstrapScript = ResolveBootstrapScript(_manifestDirectory);
if (bootstrapScript is null)
{
Logger.LogWarning(
$"Bootstrap loader not found for {_manifest.Name}; launching the entry directly. A stray top-level stdout write can corrupt the protocol until the SDK bootstrap is installed.");
}
// Resolve an absolute node.exe from PATH rather than launching the bare name
// "node". The process working directory is the extension's own folder, so a bare
// name could otherwise resolve a node.exe planted there; an absolute path avoids
// that and lets us report a specific "Node.js not found" error.
var nodeExecutable = NodeRuntimeLocator.ResolveNodeExecutable();
if (nodeExecutable is null)
{
Logger.LogError(
$"Node.js runtime (node.exe) was not found on PATH; cannot start JS extension {_manifest.Name}. Install Node.js and ensure it is on PATH.");
return;
}
Process? nodeProcess = null;
JsonRpcConnection? connection = null;
try
{
var psi = new ProcessStartInfo
{
FileName = nodeExecutable,
Arguments = BuildNodeArguments(entryPoint, bootstrapScript),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = _manifestDirectory,
};
nodeProcess = Process.Start(psi);
if (nodeProcess is null)
{
Logger.LogError($"Failed to start Node.js process for {_manifest.Name}");
return;
}
connection = new JsonRpcConnection(
nodeProcess.StandardOutput.BaseStream,
nodeProcess.StandardInput.BaseStream,
nodeProcess.StandardError.BaseStream);
connection.Error += OnConnectionError;
connection.Disconnected += OnConnectionDisconnected;
var disposedDuringStart = false;
lock (_lock)
{
// If a dispose landed while the process was starting, do not resurrect the
// wrapper by assigning the fresh handles. Reap the just-started process
// below instead so it cannot leak past disposal.
if (_isDisposed || _stopping)
{
disposedDuringStart = true;
}
else
{
_nodeProcess = nodeProcess;
_connection = connection;
// Create the provider proxy before the initialize handshake so its host
// notification handlers are registered in time to receive notifications
// (logs, statuses, clipboard requests, items-changed) that the extension
// emits while it activates during initialize.
_commandProviderProxy = new JSCommandProviderProxy(
connection,
_manifest.Name ?? "unknown",
_manifest.EffectiveDisplayName,
_manifest.Icon);
}
}
if (disposedDuringStart)
{
Logger.LogDebug($"JS extension {_manifest.Name} was disposed while starting; reaping the new process.");
ReapOrphanedStart(nodeProcess, connection);
return;
}
connection.StartListening();
var initResponse = await connection.SendRequestAsync(
"initialize",
new JsonObject { ["extensionId"] = _manifest.Name },
CancellationToken.None).ConfigureAwait(false);
if (initResponse.Error is not null)
{
Logger.LogError($"Initialization failed for {_manifest.Name}: {initResponse.Error.Message}");
SignalDispose();
return;
}
RecordAdvertisedCapabilities(initResponse.Result);
// Thread the real provider metadata from the handshake into the proxy so the
// author-specified frozen value flows through instead of the wire default.
var providerMetadata = ExtractProviderMetadata(initResponse.Result);
if (providerMetadata is { } metadata)
{
JSCommandProviderProxy? proxy;
lock (_lock)
{
proxy = _commandProviderProxy;
}
proxy?.SetProviderMetadata(metadata);
}
// A successful start clears the consecutive-crash history.
ResetCrashCount();
Logger.LogInfo($"Successfully started JS extension {_manifest.EffectiveDisplayName}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to start JS extension {_manifest.Name}: {ex.Message}");
try
{
if (nodeProcess is not null && !nodeProcess.HasExited)
{
nodeProcess.Kill(entireProcessTree: true);
}
}
catch (Exception killEx) when (killEx is InvalidOperationException or System.ComponentModel.Win32Exception)
{
// Best effort.
}
SignalDispose();
}
}
private void ReapOrphanedStart(Process nodeProcess, JsonRpcConnection connection)
{
connection.Error -= OnConnectionError;
connection.Disconnected -= OnConnectionDisconnected;
try
{
connection.Dispose();
}
catch (Exception ex)
{
Logger.LogDebug($"Error disposing orphaned connection for {_manifest.Name}: {ex.Message}");
}
try
{
if (!nodeProcess.HasExited)
{
nodeProcess.Kill(entireProcessTree: true);
nodeProcess.WaitForExit(2000);
}
}
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
{
// Best effort.
}
nodeProcess.Dispose();
}
public void SignalDispose()
{
Process? process;
JsonRpcConnection? connection;
JSCommandProviderProxy? proxy;
lock (_lock)
{
_isDisposed = true;
_stopping = true;
process = _nodeProcess;
connection = _connection;
proxy = _commandProviderProxy;
_nodeProcess = null;
_connection = null;
_commandProviderProxy = null;
}
TearDown(process, connection, proxy);
}
public void Dispose() => SignalDispose();
public IExtension? GetExtensionObject()
{
// JS extensions have no WinRT COM object; the wrapper itself is the bridge.
return null;
}
public void AddProviderType(ProviderType providerType)
{
lock (_lock)
{
if (!_providerTypes.Contains(providerType))
{
_providerTypes.Add(providerType);
}
}
}
public bool HasProviderType(ProviderType providerType)
{
lock (_lock)
{
return _providerTypes.Contains(providerType);
}
}
public async Task<T?> GetProviderAsync<T>()
where T : class
{
if (typeof(T) != typeof(ICommandProvider))
{
return null;
}
await StartExtensionAsync().ConfigureAwait(false);
lock (_lock)
{
if (_connection is null || !IsRunningLocked())
{
return null;
}
_commandProviderProxy ??= new JSCommandProviderProxy(
_connection,
_manifest.Name ?? "unknown",
_manifest.EffectiveDisplayName,
_manifest.Icon);
return _commandProviderProxy as T;
}
}
public async Task<IEnumerable<T>> GetListOfProvidersAsync<T>()
where T : class
{
var provider = await GetProviderAsync<T>().ConfigureAwait(false);
return provider is not null ? [provider] : [];
}
/// <summary>
/// Records a consecutive crash and updates <see cref="IsHealthy"/>. Extracted so the
/// crash-counter state machine can be exercised without spawning a Node.js process.
/// </summary>
/// <returns>The new consecutive crash count.</returns>
internal int RecordUnexpectedExit()
{
lock (_lock)
{
_consecutiveCrashCount++;
if (_consecutiveCrashCount > MaxConsecutiveCrashes)
{
IsHealthy = false;
}
return _consecutiveCrashCount;
}
}
/// <summary>
/// Resets the consecutive crash counter and marks the extension healthy again.
/// </summary>
internal void ResetCrashCount()
{
lock (_lock)
{
_consecutiveCrashCount = 0;
IsHealthy = true;
}
}
private void OnConnectionError(object? sender, JsonRpcErrorEventArgs e)
{
Logger.LogError($"JSON-RPC error in {_manifest.Name}: {e.Exception.Message}");
}
private void OnConnectionDisconnected(object? sender, EventArgs e)
{
Process? process;
JsonRpcConnection? connection;
JSCommandProviderProxy? proxy;
lock (_lock)
{
// Ignore disconnections that we triggered while stopping or disposing.
if (_stopping || _isDisposed)
{
return;
}
_consecutiveCrashCount++;
Logger.LogWarning($"Node.js process for {_manifest.Name} disconnected unexpectedly (crash #{_consecutiveCrashCount})");
if (_consecutiveCrashCount > MaxConsecutiveCrashes)
{
IsHealthy = false;
Logger.LogError($"JS extension {_manifest.Name} marked unhealthy after {_consecutiveCrashCount} consecutive crashes");
}
process = _nodeProcess;
connection = _connection;
proxy = _commandProviderProxy;
_nodeProcess = null;
_connection = null;
_commandProviderProxy = null;
}
// This runs on the connection's read-loop thread, and JsonRpcConnection.Dispose()
// joins that thread. Tear the handles down and notify the service on a background
// thread to avoid a self-join and to keep the read loop from blocking on itself.
_ = Task.Run(() =>
{
TearDown(process, connection, proxy);
ProcessExited?.Invoke(this, EventArgs.Empty);
});
}
private void TearDown(Process? process, JsonRpcConnection? connection, JSCommandProviderProxy? proxy)
{
// Dispose the provider proxy first so it detaches its notification handlers and
// hides any active statuses while the host is still valid, including when the
// extension process exits unexpectedly. The proxy is idempotent, so this does not
// double-dispose if the service also disposes it during provider teardown.
if (proxy is not null)
{
try
{
proxy.Dispose();
}
catch (Exception ex)
{
Logger.LogDebug($"Error disposing provider proxy for {_manifest.Name}: {ex.Message}");
}
}
if (connection is not null)
{
connection.Error -= OnConnectionError;
connection.Disconnected -= OnConnectionDisconnected;
try
{
var stillRunning = process is not null && !process.HasExited;
if (stillRunning)
{
// Ask the extension to clean up, giving it a short grace period.
connection.SendNotificationAsync("dispose", null, CancellationToken.None)
.Wait(TimeSpan.FromSeconds(2));
}
}
catch (Exception ex) when (ex is AggregateException or InvalidOperationException or JsonRpcException)
{
Logger.LogWarning($"Error sending dispose notification to {_manifest.Name}: {ex.Message}");
}
try
{
connection.Dispose();
}
catch (Exception ex)
{
Logger.LogDebug($"Error disposing JSON-RPC connection for {_manifest.Name}: {ex.Message}");
}
}
if (process is not null)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(2000);
}
}
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
{
Logger.LogWarning($"Error terminating Node.js process for {_manifest.Name}: {ex.Message}");
}
process.Dispose();
}
}
private static JsonElement? ExtractProviderMetadata(JsonElement? result)
{
if (result is not { } initResult ||
initResult.ValueKind != JsonValueKind.Object)
{
return null;
}
if ((initResult.TryGetProperty("provider", out var provider) ||
initResult.TryGetProperty("Provider", out provider)) &&
provider.ValueKind == JsonValueKind.Object)
{
// Clone so the metadata survives the disposal of the response document.
return provider.Clone();
}
return null;
}
private void RecordAdvertisedCapabilities(JsonElement? result)
{
if (result is not { } initResult ||
initResult.ValueKind != JsonValueKind.Object ||
!initResult.TryGetProperty("capabilities", out var capsElement) ||
capsElement.ValueKind != JsonValueKind.Array)
{
return;
}
var capabilities = new List<string>();
foreach (var cap in capsElement.EnumerateArray())
{
if (cap.ValueKind == JsonValueKind.String)
{
var value = cap.GetString();
if (!string.IsNullOrEmpty(value))
{
capabilities.Add(value);
}
}
}
Capabilities = capabilities;
if (capabilities.Count > 0)
{
Logger.LogInfo($"Extension {_manifest.Name} advertised capabilities: {string.Join(", ", capabilities)}");
}
}
private string BuildNodeArguments(string entryPoint, string? bootstrapScript)
{
// node [--inspect=<port>] "<bootstrap>" "<entry>" when the bootstrap resolves,
// otherwise node [--inspect=<port>] "<entry>". The bootstrap reads the entry from
// process.argv[2]; Node runtime flags such as --inspect never enter process.argv,
// so the entry stays at argv[2] regardless of debug mode.
var target = bootstrapScript is null
? $"\"{entryPoint}\""
: $"\"{bootstrapScript}\" \"{entryPoint}\"";
if (_manifest.Debug)
{
var port = _manifest.DebugPort ?? Interlocked.Increment(ref _nextDebugPort);
Logger.LogInfo($"Debug mode enabled for {_manifest.Name} on inspector port {port}");
return $"--inspect={port} {target}";
}
return target;
}
/// <summary>
/// Resolves the Phase 1 SDK bootstrap loader for an installed extension. The bootstrap
/// claims and guards stdout before it dynamically imports the extension entry, so a
/// static top-level stdout write cannot corrupt the JSON-RPC framing. Resolution is
/// relative to the extension's installed SDK
/// (<c>&lt;manifestDirectory&gt;/node_modules/@microsoft/cmdpal-sdk</c>), preferring the
/// package's declared <c>bin</c> entry and falling back to the known published
/// artifacts. Returns <see langword="null"/> when the SDK or its bootstrap is not present.
/// </summary>
internal static string? ResolveBootstrapScript(string manifestDirectory)
{
if (string.IsNullOrEmpty(manifestDirectory))
{
return null;
}
var sdkRoot = Path.Combine(manifestDirectory, "node_modules", "@microsoft", "cmdpal-sdk");
if (!Directory.Exists(sdkRoot))
{
return null;
}
// Prefer the SDK package's declared bin entry so the launch tracks the published
// contract rather than a hardcoded artifact path.
var fromBin = ResolveBootstrapFromPackageJson(sdkRoot);
if (fromBin is not null && File.Exists(fromBin))
{
return fromBin;
}
foreach (var candidate in new[]
{
Path.Combine(sdkRoot, "dist", "runtime", "bootstrap.js"),
Path.Combine(sdkRoot, "bin", "cmdpal-bootstrap.mjs"),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static string? ResolveBootstrapFromPackageJson(string sdkRoot)
{
var packageJsonPath = Path.Combine(sdkRoot, "package.json");
if (!File.Exists(packageJsonPath))
{
return null;
}
try
{
using var document = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
if (!document.RootElement.TryGetProperty("bin", out var bin))
{
return null;
}
string? relative = null;
if (bin.ValueKind == JsonValueKind.String)
{
relative = bin.GetString();
}
else if (bin.ValueKind == JsonValueKind.Object)
{
if (bin.TryGetProperty("cmdpal-bootstrap", out var named) && named.ValueKind == JsonValueKind.String)
{
relative = named.GetString();
}
else
{
foreach (var property in bin.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String)
{
relative = property.Value.GetString();
break;
}
}
}
}
if (string.IsNullOrEmpty(relative))
{
return null;
}
return Path.GetFullPath(Path.Combine(sdkRoot, relative));
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return null;
}
}
}

View File

@@ -0,0 +1,221 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Serializes all lifecycle operations (initial load, refresh, crash-restart,
/// hot-reload, and removal) for a single extension directory so that concurrent
/// triggers cannot launch duplicate processes for the same extension. The gate is
/// keyed by the canonical directory path (case-insensitive), matching the rest of
/// the service's directory comparisons.
/// </summary>
/// <remarks>
/// Entries are reference counted. An entry stays alive while any caller holds or is
/// waiting on it, so <see cref="Remove"/> during a concurrent acquire never disposes
/// a semaphore out from under a waiter (which would surface as an
/// <see cref="ObjectDisposedException"/>). The backing semaphore is disposed only
/// once the last reference is released after a removal, or when the gate itself is
/// disposed.
/// </remarks>
internal sealed partial class DirectoryLifecycleGate : IDisposable
{
private readonly Lock _lock = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
/// <summary>
/// Produces the canonical key for a directory: an absolute path with any trailing
/// separator trimmed. Invalid paths fall back to the trimmed original so callers
/// still get a stable key rather than an exception.
/// </summary>
/// <param name="directory">The directory to canonicalize.</param>
/// <returns>The canonical key used to group lifecycle operations.</returns>
public static string Canonicalize(string directory)
{
if (string.IsNullOrEmpty(directory))
{
return string.Empty;
}
try
{
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return Path.TrimEndingDirectorySeparator(directory);
}
}
/// <summary>
/// Acquires exclusive access to the lifecycle of a directory. Dispose the returned
/// handle to release it. Operations for different directories run concurrently;
/// operations for the same directory are serialized.
/// </summary>
/// <param name="directory">The extension directory whose lifecycle is being changed.</param>
/// <param name="cancellationToken">A token that cancels the wait.</param>
/// <returns>A handle that releases the gate when disposed.</returns>
public async Task<IDisposable> AcquireAsync(string directory, CancellationToken cancellationToken)
{
var key = Canonicalize(directory);
Entry entry;
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_entries.TryGetValue(key, out var existing))
{
existing = new Entry();
_entries[key] = existing;
}
existing.Refs++;
entry = existing;
}
try
{
await entry.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
// The wait was canceled (or failed); drop the reference we took so the
// entry can be cleaned up and does not leak.
ReleaseReference(key, entry, releaseSemaphore: false);
throw;
}
return new Releaser(this, key, entry);
}
/// <summary>
/// Marks a directory's gate entry for removal. When no one holds or awaits it the
/// entry is evicted and its semaphore disposed immediately. When a holder or waiter
/// still exists the entry is left in place (marked removed) so that any operation
/// already queued behind it, and any operation that arrives before it drains, keep
/// serializing on the same semaphore. The entry is evicted only once its last
/// reference is released (see <see cref="ReleaseReference"/>). This guarantees a new
/// generation for the directory strictly supersedes the prior one and can never run
/// concurrently with it.
/// </summary>
/// <param name="directory">The directory whose gate entry should be released.</param>
public void Remove(string directory)
{
var key = Canonicalize(directory);
lock (_lock)
{
if (_entries.TryGetValue(key, out var entry))
{
entry.Removed = true;
if (entry.Refs == 0)
{
_entries.Remove(key);
entry.Semaphore.Dispose();
}
// Otherwise keep the entry in the map so later acquires reuse it and stay
// serialized behind the drain; ReleaseReference evicts it at Refs == 0.
}
}
}
public void Dispose()
{
lock (_lock)
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var entry in _entries.Values)
{
entry.Removed = true;
if (entry.Refs == 0)
{
entry.Semaphore.Dispose();
}
}
_entries.Clear();
}
}
private void ReleaseReference(string key, Entry entry, bool releaseSemaphore)
{
if (releaseSemaphore)
{
try
{
entry.Semaphore.Release();
}
catch (ObjectDisposedException)
{
// The gate was disposed while this operation held it; nothing to release.
}
}
lock (_lock)
{
entry.Refs--;
if (entry.Refs == 0 && entry.Removed)
{
// The last reference to a removed entry is gone; evict it so the next
// acquire for this directory starts a fresh generation, and dispose the
// semaphore. Guard against evicting a different entry that may have taken
// this key (belt and suspenders; a removed entry is never replaced while
// it is still present).
if (_entries.TryGetValue(key, out var current) && ReferenceEquals(current, entry))
{
_entries.Remove(key);
}
entry.Semaphore.Dispose();
}
}
}
private sealed class Entry
{
public SemaphoreSlim Semaphore { get; } = new(1, 1);
public int Refs { get; set; }
public bool Removed { get; set; }
}
private sealed partial class Releaser : IDisposable
{
private readonly DirectoryLifecycleGate _gate;
private readonly string _key;
private readonly Entry _entry;
private bool _released;
public Releaser(DirectoryLifecycleGate gate, string key, Entry entry)
{
_gate = gate;
_key = key;
_entry = entry;
}
public void Dispose()
{
if (_released)
{
return;
}
_released = true;
_gate.ReleaseReference(_key, _entry, releaseSemaphore: true);
}
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Coalesces rapid file-change notifications per key (extension directory) into a
/// single delayed callback. Changes under <c>node_modules</c> are ignored. Used by
/// <see cref="JsonRpcExtensionService"/> to debounce hot-reloads while a developer saves.
/// </summary>
internal sealed partial class HotReloadDebouncer : IDisposable
{
private readonly TimeSpan _delay;
private readonly Action<string> _callback;
private readonly Lock _lock = new();
private readonly Dictionary<string, Timer> _timers = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
// Monotonic load generation. A timer captures the generation current when it is
// (re)armed; when the service stops it advances the generation so a callback that was
// already queued before the stop is dropped instead of firing a hot-reload against the
// next load generation.
private long _generation;
/// <summary>
/// Initializes a new instance of the <see cref="HotReloadDebouncer"/> class.
/// </summary>
/// <param name="callback">Invoked with the key once a key has been quiet for the debounce delay.</param>
/// <param name="delay">The debounce window. Defaults to 500 ms when null.</param>
public HotReloadDebouncer(Action<string> callback, TimeSpan? delay = null)
{
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
_delay = delay ?? TimeSpan.FromMilliseconds(500);
}
/// <summary>
/// Returns a value indicating whether the given path represents a change that should
/// trigger a hot-reload (that is, it is not inside a <c>node_modules</c> directory).
/// </summary>
/// <param name="changedPath">The full path of the changed file.</param>
/// <returns>True when the change is relevant; otherwise false.</returns>
public static bool IsRelevantChange(string changedPath)
{
if (string.IsNullOrEmpty(changedPath))
{
return false;
}
return changedPath.IndexOf("node_modules", StringComparison.OrdinalIgnoreCase) < 0;
}
/// <summary>
/// Notifies the debouncer of a change to <paramref name="changedPath"/> for the given key.
/// Irrelevant changes are dropped; relevant ones (re)start the debounce window.
/// </summary>
/// <param name="key">The key that groups the change, typically the extension directory.</param>
/// <param name="changedPath">The full path of the changed file.</param>
public void Notify(string key, string changedPath)
{
if (string.IsNullOrEmpty(key) || !IsRelevantChange(changedPath))
{
return;
}
lock (_lock)
{
if (_disposed)
{
return;
}
if (_timers.TryGetValue(key, out var existing))
{
existing.Change(_delay, Timeout.InfiniteTimeSpan);
return;
}
// Capture the current generation so a callback that fires after a stop, but
// whose timer was armed before it, is dropped. A stop clears the timer map, so
// any timer found in the map above was armed in the current generation.
var state = new PendingReload(key, _generation);
_timers[key] = new Timer(OnTimerElapsed, state, _delay, Timeout.InfiniteTimeSpan);
}
}
/// <summary>
/// Cancels any pending debounce for the given key.
/// </summary>
/// <param name="key">The key to cancel.</param>
public void Cancel(string key)
{
lock (_lock)
{
if (_timers.TryGetValue(key, out var timer))
{
timer.Dispose();
_timers.Remove(key);
}
}
}
/// <summary>
/// Cancels every pending debounce and advances the load generation. Callbacks whose
/// timer was armed before this call are dropped even if they had already been queued to
/// the thread pool, so a hot-reload notified in a prior generation cannot fire against
/// the next one. Used when the service stops between load cycles.
/// </summary>
public void CancelAll()
{
lock (_lock)
{
if (_disposed)
{
return;
}
_generation++;
foreach (var timer in _timers.Values)
{
timer.Dispose();
}
_timers.Clear();
}
}
public void Dispose()
{
lock (_lock)
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var timer in _timers.Values)
{
timer.Dispose();
}
_timers.Clear();
}
}
private void OnTimerElapsed(object? state)
{
var pending = (PendingReload)state!;
lock (_lock)
{
if (_disposed)
{
return;
}
// Drop a callback captured in an earlier generation (the service stopped after
// this timer was armed), so it cannot drive a reload in the current generation.
if (pending.Generation != _generation)
{
return;
}
if (_timers.TryGetValue(pending.Key, out var timer))
{
timer.Dispose();
_timers.Remove(pending.Key);
}
}
_callback(pending.Key);
}
private readonly record struct PendingReload(string Key, long Generation);
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Resolves an absolute path to the Node.js runtime (<c>node.exe</c>) by probing the
/// process PATH. Launching an explicit, validated absolute path rather than the bare
/// name <c>node</c> keeps <see cref="System.Diagnostics.Process"/> from resolving
/// <c>node.exe</c> out of the spawning process's working directory (which for a JS
/// extension is the extension's own, untrusted, folder) or via other implicit search
/// locations. It also lets the caller surface a specific "Node.js not found" error
/// instead of a generic Win32 launch failure.
/// </summary>
internal static class NodeRuntimeLocator
{
private const string NodeExecutableName = "node.exe";
/// <summary>
/// Resolves <c>node.exe</c> from the current process PATH.
/// </summary>
/// <returns>The absolute path to <c>node.exe</c>, or <see langword="null"/> when it is not on PATH.</returns>
internal static string? ResolveNodeExecutable() => ResolveNodeExecutable(GetPathDirectories());
/// <summary>
/// Resolves <c>node.exe</c> from an explicit ordered list of directories. Exposed for testing.
/// </summary>
/// <param name="pathDirectories">The directories to probe, in priority order.</param>
/// <returns>The absolute path to the first existing <c>node.exe</c>, or <see langword="null"/>.</returns>
internal static string? ResolveNodeExecutable(IReadOnlyList<string> pathDirectories)
{
ArgumentNullException.ThrowIfNull(pathDirectories);
foreach (var directory in pathDirectories)
{
string candidate;
try
{
candidate = Path.Combine(directory, NodeExecutableName);
}
catch (ArgumentException)
{
// Malformed PATH entry; skip it.
continue;
}
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static IReadOnlyList<string> GetPathDirectories()
{
var pathVariable = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathVariable))
{
return [];
}
return pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// A synchronized registry of provider ids (normalized manifest name keys) that are
/// currently claimed by a loaded extension, keyed to the canonical directory that owns
/// them. Every registration path (initial scan, refresh, dynamic install, hot-reload,
/// and crash-restart) consults and claims the same registry as one atomic step, so two
/// extensions can never register the same provider id regardless of how they arrive.
/// </summary>
/// <remarks>
/// The registry keeps its own lock so it is independently thread-safe. The service also
/// invokes it while holding its extensions lock so that the id claim and the in-memory
/// extension list stay consistent as a single atomic operation.
/// </remarks>
internal sealed class ProviderIdReservations
{
private readonly Lock _lock = new();
// Provider id (ordinal name key) -> canonical directory that owns it.
private readonly Dictionary<string, string> _owners = new(StringComparer.Ordinal);
/// <summary>
/// Atomically claims <paramref name="providerId"/> for <paramref name="canonicalDirectory"/>.
/// An empty provider id is never reserved (there is nothing to collide on).
/// </summary>
/// <param name="providerId">The normalized provider id (manifest name key).</param>
/// <param name="canonicalDirectory">The canonical directory attempting to own the id.</param>
/// <returns>
/// True when the id is now owned by <paramref name="canonicalDirectory"/> (either newly
/// claimed or already owned by the same directory); false when a different directory
/// already owns it.
/// </returns>
public bool TryReserve(string? providerId, string canonicalDirectory)
{
if (string.IsNullOrEmpty(providerId))
{
return true;
}
lock (_lock)
{
if (_owners.TryGetValue(providerId, out var owner))
{
return string.Equals(owner, canonicalDirectory, StringComparison.OrdinalIgnoreCase);
}
_owners[providerId] = canonicalDirectory;
return true;
}
}
/// <summary>
/// Releases <paramref name="providerId"/> only when it is currently owned by
/// <paramref name="canonicalDirectory"/>, so a stale release from a different owner
/// cannot free an id that has since been claimed by someone else.
/// </summary>
/// <param name="providerId">The normalized provider id (manifest name key).</param>
/// <param name="canonicalDirectory">The canonical directory releasing the id.</param>
public void Release(string? providerId, string canonicalDirectory)
{
if (string.IsNullOrEmpty(providerId))
{
return;
}
lock (_lock)
{
if (_owners.TryGetValue(providerId, out var owner) &&
string.Equals(owner, canonicalDirectory, StringComparison.OrdinalIgnoreCase))
{
_owners.Remove(providerId);
}
}
}
/// <summary>
/// Drops every reservation. Used when the service stops or is disposed and all
/// extensions are torn down together.
/// </summary>
public void Clear()
{
lock (_lock)
{
_owners.Clear();
}
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// A cancellation source that can be reused across successive service load cycles.
/// A single <see cref="CancellationTokenSource"/> can only transition to canceled
/// once, so a service that shares one token between "stop" and "load again" would
/// keep handing out an already-canceled token after the first stop. This wrapper
/// hands out the live token, lets callers request a stop, and swaps in a fresh
/// source when a new load cycle begins, disposing the previous one safely.
/// </summary>
internal sealed partial class ReloadCancellation : IDisposable
{
private readonly Lock _lock = new();
private CancellationTokenSource _cts = new();
private bool _disposed;
/// <summary>
/// Gets the token for the current load cycle. Once the wrapper has been stopped
/// or disposed the returned token is already canceled, so callers observe the
/// stop request without touching a disposed source.
/// </summary>
public CancellationToken Token
{
get
{
lock (_lock)
{
if (_disposed || _cts.IsCancellationRequested)
{
return new CancellationToken(canceled: true);
}
return _cts.Token;
}
}
}
/// <summary>
/// Gets a value indicating whether a stop (or dispose) has been requested for the
/// current cycle. New work should bail out when this is true.
/// </summary>
public bool IsStopRequested
{
get
{
lock (_lock)
{
return _disposed || _cts.IsCancellationRequested;
}
}
}
/// <summary>
/// Ensures a fresh, uncanceled token is available for a new load cycle. When the
/// current source has already been canceled it is disposed and replaced. Returns
/// the token for the new cycle. After the wrapper has been disposed this returns
/// an already-canceled token instead of throwing.
/// </summary>
/// <returns>The token that governs the newly started cycle.</returns>
public CancellationToken BeginCycle()
{
lock (_lock)
{
if (_disposed)
{
return new CancellationToken(canceled: true);
}
if (_cts.IsCancellationRequested)
{
_cts.Dispose();
_cts = new CancellationTokenSource();
}
return _cts.Token;
}
}
/// <summary>
/// Requests cancellation of the current cycle without disposing the source, so
/// in-flight callers that already captured the token observe the cancellation.
/// The source is replaced on the next <see cref="BeginCycle"/>.
/// </summary>
public void Stop()
{
lock (_lock)
{
if (_disposed)
{
return;
}
if (!_cts.IsCancellationRequested)
{
_cts.Cancel();
}
}
}
public void Dispose()
{
lock (_lock)
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
_cts.Cancel();
}
catch (ObjectDisposedException)
{
}
_cts.Dispose();
}
}
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
using ManagedCommon;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// A single ordered dispatch path for provider add/remove notifications. Every emission
/// is enqueued and run by one worker in strict first-in-first-out order, so a consumer
/// can never observe a provider addition before the removal that was enqueued ahead of
/// it, even when the two originate on different threads (a hot-reload swap, a crash
/// restart, an install, and an uninstall can all race).
/// </summary>
/// <remarks>
/// The service raises the paired removal and addition of a hot-reload or crash-restart as
/// a single enqueued action so the pair is never split by another operation's emission.
/// Because a slow consumer handler runs on the worker rather than the caller, an emission
/// cannot deadlock or reorder against the operation that produced it.
/// </remarks>
internal sealed class SerialNotificationDispatcher : IDisposable
{
private readonly Channel<Action> _queue = Channel.CreateUnbounded<Action>(
new UnboundedChannelOptions
{
SingleReader = true,
AllowSynchronousContinuations = false,
});
private readonly Task _worker;
private bool _disposed;
public SerialNotificationDispatcher()
{
_worker = Task.Run(RunAsync);
}
/// <summary>
/// Enqueues a notification to be raised on the worker after every notification already
/// enqueued. Dropped silently once the dispatcher has been disposed.
/// </summary>
/// <param name="notification">The emission to run in order.</param>
public void Enqueue(Action notification)
{
ArgumentNullException.ThrowIfNull(notification);
if (_disposed)
{
return;
}
_queue.Writer.TryWrite(notification);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Stop accepting new notifications and let the worker drain to completion so a
// removal already queued ahead of an addition is not stranded.
_queue.Writer.TryComplete();
try
{
_worker.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// The worker swallows handler exceptions; nothing actionable here.
}
}
private async Task RunAsync()
{
try
{
while (await _queue.Reader.WaitToReadAsync().ConfigureAwait(false))
{
while (_queue.Reader.TryRead(out var notification))
{
try
{
notification();
}
catch (Exception ex)
{
Logger.LogError($"A provider notification handler threw: {ex.Message}");
}
}
}
}
catch (ChannelClosedException)
{
}
}
}

View File

@@ -271,6 +271,7 @@ public partial class App : Application, IDisposable
// Load IExtensionServices here
services.AddSingleton<IExtensionService, BuiltInExtensionService>();
services.AddSingleton<IExtensionService, WinRTExtensionService>();
services.AddSingleton<IExtensionService, JsonRpcExtensionService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();

View File

@@ -0,0 +1,206 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the per-directory lifecycle gate (p4-02). Concurrent triggers (initial
/// load, refresh, crash-restart, hot-reload) must be serialized per canonical
/// directory so they cannot launch duplicate processes, while different directories
/// still run concurrently. Removing an entry during a concurrent acquire must not
/// throw an ObjectDisposedException.
/// </summary>
[TestClass]
public class DirectoryLifecycleGateTests
{
[TestMethod]
public void Canonicalize_TrailingSeparatorAndCase_ProduceSameKey()
{
var a = DirectoryLifecycleGate.Canonicalize(@"C:\temp\Ext");
var b = DirectoryLifecycleGate.Canonicalize(@"C:\temp\Ext\");
Assert.AreEqual(a, b);
}
[TestMethod]
public async Task AcquireAsync_SameDirectory_SerializesOperations()
{
using var gate = new DirectoryLifecycleGate();
const string Dir = @"C:\temp\extension-a";
var running = 0;
var maxConcurrent = 0;
var sync = new object();
async Task Operation()
{
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
lock (sync)
{
running++;
maxConcurrent = Math.Max(maxConcurrent, running);
}
await Task.Delay(25);
lock (sync)
{
running--;
}
}
}
await Task.WhenAll(Operation(), Operation(), Operation(), Operation());
Assert.AreEqual(1, maxConcurrent, "Operations for one directory must never overlap.");
}
[TestMethod]
public async Task AcquireAsync_DifferentDirectories_RunConcurrently()
{
using var gate = new DirectoryLifecycleGate();
var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var firstTask = Task.Run(async () =>
{
using (await gate.AcquireAsync(@"C:\temp\dir-1", CancellationToken.None))
{
firstEntered.SetResult();
await releaseFirst.Task;
}
});
await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
// A different directory must be acquirable while the first is still held.
using (await gate.AcquireAsync(@"C:\temp\dir-2", CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)))
{
Assert.IsTrue(true, "Acquired a second directory while the first was held.");
}
releaseFirst.SetResult();
await firstTask.WaitAsync(TimeSpan.FromSeconds(5));
}
[TestMethod]
public async Task Remove_DuringConcurrentAcquire_DoesNotThrow()
{
using var gate = new DirectoryLifecycleGate();
const string Dir = @"C:\temp\removed-dir";
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
// Removing the entry while it is held marks it for removal; the release
// that follows must not throw even though the entry was removed.
gate.Remove(Dir);
}
// Re-acquiring after a removal transparently creates a fresh entry.
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
Assert.IsTrue(true);
}
}
[TestMethod]
public async Task Remove_WhileHeld_NewAcquireSerializesBehindPriorGeneration()
{
using var gate = new DirectoryLifecycleGate();
const string Dir = @"C:\temp\overlap-dir";
var aEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseA = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var cEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var aTask = Task.Run(async () =>
{
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
aEntered.SetResult();
await releaseA.Task;
}
});
await aEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Remove the directory while the prior generation (A) still holds it. A new
// generation must strictly supersede it, never overlap it.
gate.Remove(Dir);
var cTask = Task.Run(async () =>
{
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
cEntered.SetResult();
}
});
// C must not enter while A still holds the gate, even though Remove was called in
// between. Before the fix, Remove evicted the entry immediately, so C would create
// a fresh entry with a new semaphore and run concurrently with A.
var enteredEarly = await Task.WhenAny(cEntered.Task, Task.Delay(200)) == cEntered.Task;
Assert.IsFalse(enteredEarly, "A new generation after Remove must serialize behind the still-live prior generation.");
releaseA.SetResult();
await cEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await Task.WhenAll(aTask, cTask).WaitAsync(TimeSpan.FromSeconds(5));
}
[TestMethod]
public async Task Remove_WhileHeld_OverlappingCycles_NeverRunConcurrently()
{
using var gate = new DirectoryLifecycleGate();
const string Dir = @"C:\temp\overlap-cycles";
var running = 0;
var maxConcurrent = 0;
var sync = new object();
async Task Cycle()
{
using (await gate.AcquireAsync(Dir, CancellationToken.None))
{
lock (sync)
{
running++;
maxConcurrent = Math.Max(maxConcurrent, running);
}
await Task.Delay(15);
// Marking the directory for removal mid-cycle starts a fresh generation for
// any queued acquire; it must still not overlap this one.
gate.Remove(Dir);
lock (sync)
{
running--;
}
}
}
await Task.WhenAll(Cycle(), Cycle(), Cycle(), Cycle()).WaitAsync(TimeSpan.FromSeconds(10));
Assert.AreEqual(1, maxConcurrent, "Overlapping begin/complete cycles for one directory must never run concurrently.");
}
[TestMethod]
public async Task AcquireAsync_AfterDispose_Throws()
{
var gate = new DirectoryLifecycleGate();
gate.Dispose();
await Assert.ThrowsExceptionAsync<ObjectDisposedException>(
async () => await gate.AcquireAsync(@"C:\temp\any", CancellationToken.None));
}
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class HotReloadDebouncerTests
{
[TestMethod]
public void IsRelevantChange_NodeModulesPath_IsIgnored()
{
Assert.IsFalse(HotReloadDebouncer.IsRelevantChange(@"C:\ext\node_modules\pkg\index.js"));
Assert.IsFalse(HotReloadDebouncer.IsRelevantChange(@"C:\ext\NODE_MODULES\pkg\index.js"));
Assert.IsFalse(HotReloadDebouncer.IsRelevantChange(string.Empty));
}
[TestMethod]
public void IsRelevantChange_SourceFile_IsRelevant()
{
Assert.IsTrue(HotReloadDebouncer.IsRelevantChange(@"C:\ext\dist\index.js"));
}
[TestMethod]
public void Notify_RapidChanges_InvokesCallbackOnce()
{
var fired = new CountdownEvent(1);
var count = 0;
using var debouncer = new HotReloadDebouncer(
_ =>
{
Interlocked.Increment(ref count);
fired.Signal();
},
TimeSpan.FromMilliseconds(120));
// Simulate a burst of saves well within the debounce window.
for (var i = 0; i < 8; i++)
{
debouncer.Notify(@"C:\ext", @"C:\ext\dist\index.js");
Thread.Sleep(10);
}
Assert.IsTrue(fired.Wait(TimeSpan.FromSeconds(2)), "Callback was not invoked.");
// Give any (erroneous) extra timers a chance to fire before asserting.
Thread.Sleep(200);
Assert.AreEqual(1, Volatile.Read(ref count));
}
[TestMethod]
public void Notify_NodeModulesChange_DoesNotInvokeCallback()
{
var count = 0;
using var debouncer = new HotReloadDebouncer(
_ => Interlocked.Increment(ref count),
TimeSpan.FromMilliseconds(80));
debouncer.Notify(@"C:\ext", @"C:\ext\node_modules\pkg\index.js");
Thread.Sleep(250);
Assert.AreEqual(0, Volatile.Read(ref count));
}
[TestMethod]
public void Notify_DistinctKeys_InvokesCallbackPerKey()
{
var fired = new CountdownEvent(2);
using var debouncer = new HotReloadDebouncer(
_ => fired.Signal(),
TimeSpan.FromMilliseconds(80));
debouncer.Notify(@"C:\ext-a", @"C:\ext-a\index.js");
debouncer.Notify(@"C:\ext-b", @"C:\ext-b\index.js");
Assert.IsTrue(fired.Wait(TimeSpan.FromSeconds(2)), "Both keys should have fired.");
}
[TestMethod]
public void Cancel_PreventsPendingCallback()
{
var count = 0;
using var debouncer = new HotReloadDebouncer(
_ => Interlocked.Increment(ref count),
TimeSpan.FromMilliseconds(150));
debouncer.Notify(@"C:\ext", @"C:\ext\index.js");
debouncer.Cancel(@"C:\ext");
Thread.Sleep(300);
Assert.AreEqual(0, Volatile.Read(ref count));
}
// r3-p4-09: a debounce pending when the service stops between load generations must
// not fire against the next generation. CancelAll advances the generation and cancels
// every pending timer, so a callback armed before the stop is dropped.
[TestMethod]
public void CancelAll_DropsPendingCallbacksAcrossKeys()
{
var count = 0;
using var debouncer = new HotReloadDebouncer(
_ => Interlocked.Increment(ref count),
TimeSpan.FromMilliseconds(150));
debouncer.Notify(@"C:\ext-a", @"C:\ext-a\index.js");
debouncer.Notify(@"C:\ext-b", @"C:\ext-b\index.js");
debouncer.CancelAll();
Thread.Sleep(350);
Assert.AreEqual(0, Volatile.Read(ref count), "Pending callbacks must not fire after CancelAll.");
}
// r3-p4-09: after a stop (CancelAll), a fresh Notify belongs to the new generation and
// must still fire normally, proving CancelAll cancels the prior generation only.
[TestMethod]
public void CancelAll_DoesNotBlockLaterGeneration()
{
var fired = new CountdownEvent(1);
using var debouncer = new HotReloadDebouncer(
_ => fired.Signal(),
TimeSpan.FromMilliseconds(100));
debouncer.Notify(@"C:\ext", @"C:\ext\index.js");
debouncer.CancelAll();
// A change notified after the stop starts a new generation and must fire.
debouncer.Notify(@"C:\ext", @"C:\ext\index.js");
Assert.IsTrue(fired.Wait(TimeSpan.FromSeconds(2)), "A change after CancelAll should still reload.");
}
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that the author-specified frozen value from the initialize handshake
/// metadata flows through the proxy (cp-B). When no metadata is supplied the wire
/// default is frozen; when the handshake provides a value it wins.
/// </summary>
[TestClass]
public class JSCommandProviderProxyFrozenTests
{
private static JsonElement Parse(string json)
{
// The returned element must outlive this call, so parse into a document whose
// lifetime is tied to the element via Clone rather than a disposed document.
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
[TestMethod]
public void Frozen_DefaultsToTrue_WhenNoMetadata()
{
using var fake = new JSFakeExtension();
var provider = new JSCommandProviderProxy(fake.Connection, new JSExtensionManifest { Name = "ext" });
Assert.IsTrue(provider.Frozen);
provider.Dispose();
}
[TestMethod]
public void Frozen_HonorsCtorMetadata_False()
{
using var fake = new JSFakeExtension();
var provider = new JSCommandProviderProxy(
fake.Connection,
new JSExtensionManifest { Name = "ext" },
Parse("""{ "frozen": false }"""));
Assert.IsFalse(provider.Frozen);
provider.Dispose();
}
[TestMethod]
public void Frozen_HonorsHandshakeMetadata_False()
{
using var fake = new JSFakeExtension();
var provider = new JSCommandProviderProxy(fake.Connection, new JSExtensionManifest { Name = "ext" });
provider.SetProviderMetadata(Parse("""{ "Frozen": false }"""));
Assert.IsFalse(provider.Frozen);
provider.Dispose();
}
[TestMethod]
public void Frozen_HonorsHandshakeMetadata_True()
{
using var fake = new JSFakeExtension();
var provider = new JSCommandProviderProxy(fake.Connection, new JSExtensionManifest { Name = "ext" });
provider.SetProviderMetadata(Parse("""{ "frozen": true }"""));
Assert.IsTrue(provider.Frozen);
provider.Dispose();
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that host notifications an extension emits while it activates, before the
/// host is attached, are buffered and delivered once the host attaches (p4-05). The
/// proxy is constructed first so its notification handlers are registered in time to
/// receive those startup notifications.
/// </summary>
[TestClass]
public class JSCommandProviderProxyStartupNotificationTests
{
private static JSCommandProviderProxy CreateProvider(JSFakeExtension fake) =>
new(fake.Connection, new JSExtensionManifest { Name = "startup.ext", DisplayName = "Startup Extension" });
[TestMethod]
public async Task StartupStatus_EmittedBeforeHostAttaches_IsDeliveredAfterAttach()
{
using var fake = new JSFakeExtension();
var provider = CreateProvider(fake);
// The extension raises a status during activation, before the host is attached.
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["statusId"] = "startup-1",
["message"] = new JsonObject { ["Message"] = "Starting", ["State"] = 0 },
});
var host = new RecordingExtensionHost();
provider.InitializeWithHost(host);
await host.WaitForShownCountAsync(1);
Assert.AreEqual(1, host.Shown.Count);
Assert.AreEqual("Starting", host.Shown[0].Message);
provider.Dispose();
}
[TestMethod]
public async Task StartupLog_EmittedBeforeHostAttaches_IsDeliveredAfterAttach()
{
using var fake = new JSFakeExtension();
var provider = CreateProvider(fake);
await fake.PushNotificationAsync(
"host/logMessage",
new JsonObject { ["message"] = "activating", ["state"] = 0 });
var host = new RecordingExtensionHost();
provider.InitializeWithHost(host);
await host.WaitForLogCountAsync(1);
Assert.AreEqual("activating", host.Logs[0].Message);
provider.Dispose();
}
[TestMethod]
public async Task BufferedActions_AreDeliveredInArrivalOrder()
{
using var fake = new JSFakeExtension();
var provider = CreateProvider(fake);
await fake.PushNotificationAsync(
"host/logMessage",
new JsonObject { ["message"] = "first", ["state"] = 0 });
await fake.PushNotificationAsync(
"host/logMessage",
new JsonObject { ["message"] = "second", ["state"] = 0 });
var host = new RecordingExtensionHost();
provider.InitializeWithHost(host);
await host.WaitForLogCountAsync(2);
Assert.AreEqual("first", host.Logs[0].Message);
Assert.AreEqual("second", host.Logs[1].Message);
provider.Dispose();
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that status hiding cannot race disposal and strand UI (r2-p4-08). A status
/// show is only ever delivered to the host while the proxy is not disposed, and disposal
/// hides every active status under the same lock, so once teardown has hidden statuses no
/// later show can resurrect them. The guarantee under test is the invariant "every status
/// the host was shown is also hidden": teardown can drop a show that had not yet been
/// delivered, but it can never leave a delivered show without a matching hide.
/// </summary>
[TestClass]
public class JSCommandProviderProxyStatusDisposeRaceTests
{
private static JSCommandProviderProxy CreateInitialized(JSFakeExtension fake, out RecordingExtensionHost host)
{
host = new RecordingExtensionHost();
var provider = new JSCommandProviderProxy(
fake.Connection,
new JSExtensionManifest { Name = "status.race.ext", DisplayName = "Status Race Extension" });
provider.InitializeWithHost(host);
return provider;
}
private static JsonObject ShowStatus(string statusId, string message) => new()
{
["statusId"] = statusId,
["message"] = new JsonObject { ["Message"] = message, ["State"] = 0 },
};
[TestMethod]
public async Task Dispose_HidesActiveStatuses()
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s1", "Live"));
await host.WaitForShownCountAsync(1);
provider.Dispose();
// A status that was visible at teardown must be hidden exactly once, with the same
// message instance, so nothing is left stranded in the host UI.
Assert.AreEqual(1, host.Hidden.Count);
Assert.AreSame(host.Shown[0], host.Hidden[0]);
}
[TestMethod]
public async Task ConcurrentShowAndDispose_NeverStrandsStatus()
{
const int Iterations = 50;
for (var i = 0; i < Iterations; i++)
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
// Push a status and tear the proxy down without waiting for the show to be
// delivered, so the show handler races Dispose. Whichever wins the lock, the
// show is either delivered-then-hidden or its late delivery is dropped: it must
// never be recorded as shown without a matching hide.
var pushTask = fake.PushNotificationAsync("host/showStatus", ShowStatus($"s{i}", "Racing"));
provider.Dispose();
await pushTask;
// Give the connection's delivery thread a chance to run the (now no-op) handler
// so the assertion sees the settled state rather than an in-flight one.
await Task.Delay(5);
var shown = host.Shown;
var hidden = host.Hidden;
// The invariant: every status the host was actually shown must also have been
// hidden (matched by reference). Before the fix, a show delivered after teardown
// hid statuses would have no matching hide and would strand the UI.
foreach (var message in shown)
{
Assert.IsTrue(
hidden.Any(h => ReferenceEquals(h, message)),
"A status shown to the host must always be hidden; disposal must not strand it.");
}
provider.Dispose();
}
}
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies host status lifecycle handling on the proxy (p4-06): a repeated status id
/// updates the existing message in place rather than stacking a duplicate, a status is
/// removed by id independent of its severity, and all active statuses are cleared when
/// the extension disconnects even without an explicit hide.
/// </summary>
[TestClass]
public class JSCommandProviderProxyStatusLifecycleTests
{
private static JSCommandProviderProxy CreateInitialized(JSFakeExtension fake, out RecordingExtensionHost host)
{
host = new RecordingExtensionHost();
var provider = new JSCommandProviderProxy(
fake.Connection,
new JSExtensionManifest { Name = "status.ext", DisplayName = "Status Extension" });
provider.InitializeWithHost(host);
return provider;
}
private static JsonObject ShowStatus(string statusId, string message, int state) => new()
{
["statusId"] = statusId,
["message"] = new JsonObject { ["Message"] = message, ["State"] = state },
};
[TestMethod]
public async Task RepeatedStatusId_UpdatesInPlace_WithoutDuplicateShow()
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s1", "First", 0));
await host.WaitForShownCountAsync(1);
// Update the same status, then push a second distinct status as an ordering
// barrier: once the second status is shown, the in-place update has been applied.
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s1", "Second", 0));
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s2", "Other", 0));
await host.WaitForShownCountAsync(2);
Assert.AreEqual(2, host.Shown.Count, "The repeated status id must not produce a second ShowStatus.");
Assert.AreEqual("Second", host.Shown[0].Message, "The existing status must be updated in place.");
provider.Dispose();
}
[TestMethod]
public async Task Status_RemovedById_IndependentOfSeverity()
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
await fake.PushNotificationAsync("host/showStatus", ShowStatus("w1", "Careful", 2));
await host.WaitForShownCountAsync(1);
Assert.AreEqual(MessageState.Warning, host.Shown[0].State);
await fake.PushNotificationAsync("host/hideStatus", new JsonObject { ["statusId"] = "w1" });
await host.WaitForHiddenCountAsync(1);
Assert.AreEqual(1, host.Hidden.Count);
Assert.AreSame(host.Shown[0], host.Hidden[0], "The hidden status must be the warning shown by that id.");
provider.Dispose();
}
[TestMethod]
public async Task ActiveStatuses_AreCleared_OnDisconnect()
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s1", "Live", 0));
await host.WaitForShownCountAsync(1);
// Disconnecting the extension (its process exiting) must clear active statuses
// even though no explicit hide arrived.
fake.Dispose();
await host.WaitForHiddenCountAsync(1);
Assert.AreSame(host.Shown[0], host.Hidden[0]);
provider.Dispose();
}
// r3-p4-06: a hide must never be observed by the host before the show it cancels. The
// handlers dispatch to the host inside the same lock acquisition that mutates the status
// map, so a hide cannot overtake a pending show. Caveat: the underlying defect is a
// cross-thread timing race (the show arrives on the notification worker while a hide can
// arrive from disconnect or dispose); this is a best-effort deterministic guard that
// asserts the host observes show-before-hide and ends with the status cleared.
[TestMethod]
public async Task ShowThenHide_SameId_HostObservesShowBeforeHide()
{
using var fake = new JSFakeExtension();
var provider = CreateInitialized(fake, out var host);
await fake.PushNotificationAsync("host/showStatus", ShowStatus("s1", "Live", 0));
await fake.PushNotificationAsync("host/hideStatus", new JsonObject { ["statusId"] = "s1" });
await host.WaitForShownCountAsync(1);
await host.WaitForHiddenCountAsync(1);
Assert.AreEqual(1, host.Shown.Count);
Assert.AreEqual(1, host.Hidden.Count);
Assert.AreSame(host.Shown[0], host.Hidden[0], "The hidden status must be the one that was shown.");
provider.Dispose();
}
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.IO;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that the host launch goes through the Phase 1 SDK bootstrap (r3-p4-07). The
/// bootstrap claims and guards stdout before it dynamically imports the extension entry, so
/// static top-level stdout writes cannot corrupt the JSON-RPC framing. The launcher must
/// resolve the bootstrap relative to the extension's installed SDK under
/// <c>node_modules/@microsoft/cmdpal-sdk</c>, preferring the package's declared
/// <c>bin</c> entry, and must return null (falling back to a direct entry launch) when the
/// SDK or its bootstrap is absent.
/// </summary>
[TestClass]
public class JSExtensionWrapperBootstrapTests
{
private static string CreateSdkRoot(string manifestDir)
{
var sdkRoot = Path.Combine(manifestDir, "node_modules", "@microsoft", "cmdpal-sdk");
Directory.CreateDirectory(sdkRoot);
return sdkRoot;
}
private static string NewTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "cmdpal-bootstrap-test-" + Path.GetRandomFileName());
Directory.CreateDirectory(dir);
return dir;
}
[TestMethod]
public void ResolveBootstrapScript_PrefersPackageJsonBinEntry()
{
var manifestDir = NewTempDir();
try
{
var sdkRoot = CreateSdkRoot(manifestDir);
var binTarget = Path.Combine(sdkRoot, "dist", "runtime", "bootstrap.js");
Directory.CreateDirectory(Path.GetDirectoryName(binTarget)!);
File.WriteAllText(binTarget, "// bootstrap");
File.WriteAllText(
Path.Combine(sdkRoot, "package.json"),
"{ \"name\": \"@microsoft/cmdpal-sdk\", \"bin\": { \"cmdpal-bootstrap\": \"./dist/runtime/bootstrap.js\" } }");
var resolved = JSExtensionWrapper.ResolveBootstrapScript(manifestDir);
Assert.IsNotNull(resolved);
Assert.AreEqual(Path.GetFullPath(binTarget), Path.GetFullPath(resolved!));
}
finally
{
Directory.Delete(manifestDir, recursive: true);
}
}
[TestMethod]
public void ResolveBootstrapScript_FallsBackToKnownArtifact_WhenNoBinEntry()
{
var manifestDir = NewTempDir();
try
{
var sdkRoot = CreateSdkRoot(manifestDir);
File.WriteAllText(Path.Combine(sdkRoot, "package.json"), "{ \"name\": \"@microsoft/cmdpal-sdk\" }");
var fallback = Path.Combine(sdkRoot, "dist", "runtime", "bootstrap.js");
Directory.CreateDirectory(Path.GetDirectoryName(fallback)!);
File.WriteAllText(fallback, "// bootstrap");
var resolved = JSExtensionWrapper.ResolveBootstrapScript(manifestDir);
Assert.IsNotNull(resolved);
Assert.AreEqual(Path.GetFullPath(fallback), Path.GetFullPath(resolved!));
}
finally
{
Directory.Delete(manifestDir, recursive: true);
}
}
[TestMethod]
public void ResolveBootstrapScript_ReturnsNull_WhenSdkMissing()
{
var manifestDir = NewTempDir();
try
{
Assert.IsNull(JSExtensionWrapper.ResolveBootstrapScript(manifestDir));
}
finally
{
Directory.Delete(manifestDir, recursive: true);
}
}
[TestMethod]
public void ResolveBootstrapScript_ReturnsNull_WhenBootstrapArtifactMissing()
{
var manifestDir = NewTempDir();
try
{
// The SDK is installed but neither the bin entry target nor the known artifacts
// exist on disk, so no bootstrap can be launched.
var sdkRoot = CreateSdkRoot(manifestDir);
File.WriteAllText(
Path.Combine(sdkRoot, "package.json"),
"{ \"name\": \"@microsoft/cmdpal-sdk\", \"bin\": { \"cmdpal-bootstrap\": \"./dist/runtime/bootstrap.js\" } }");
Assert.IsNull(JSExtensionWrapper.ResolveBootstrapScript(manifestDir));
}
finally
{
Directory.Delete(manifestDir, recursive: true);
}
}
[TestMethod]
public void ResolveBootstrapScript_ReturnsNull_ForEmptyManifestDirectory()
{
Assert.IsNull(JSExtensionWrapper.ResolveBootstrapScript(string.Empty));
}
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class JSExtensionWrapperTests
{
private static JSExtensionWrapper CreateWrapper(string? version = "1.2.3") => new(
new JSExtensionManifest
{
Name = "test-ext",
DisplayName = "Test Extension",
Version = version,
Publisher = "unit-test",
Main = "index.js",
EntryPointPath = Path.Combine(Path.GetTempPath(), "index.js"),
},
Path.Combine(Path.GetTempPath(), "test-ext"));
[TestMethod]
public void NewWrapper_IsHealthy_WithZeroCrashes()
{
var wrapper = CreateWrapper();
Assert.IsTrue(wrapper.IsHealthy);
Assert.AreEqual(0, wrapper.ConsecutiveCrashCount);
Assert.IsFalse(wrapper.IsRunning());
}
[TestMethod]
public void RecordUnexpectedExit_WithinThreshold_StaysHealthy()
{
var wrapper = CreateWrapper();
for (var i = 1; i <= 3; i++)
{
Assert.AreEqual(i, wrapper.RecordUnexpectedExit());
}
Assert.IsTrue(wrapper.IsHealthy, "Three or fewer crashes should remain healthy.");
Assert.AreEqual(3, wrapper.ConsecutiveCrashCount);
}
[TestMethod]
public void RecordUnexpectedExit_AboveThreshold_BecomesUnhealthy()
{
var wrapper = CreateWrapper();
for (var i = 0; i < 4; i++)
{
wrapper.RecordUnexpectedExit();
}
Assert.IsFalse(wrapper.IsHealthy, "More than three crashes should mark the extension unhealthy.");
Assert.AreEqual(4, wrapper.ConsecutiveCrashCount);
}
[TestMethod]
public void ResetCrashCount_RestoresHealthAndCount()
{
var wrapper = CreateWrapper();
for (var i = 0; i < 5; i++)
{
wrapper.RecordUnexpectedExit();
}
Assert.IsFalse(wrapper.IsHealthy);
wrapper.ResetCrashCount();
Assert.IsTrue(wrapper.IsHealthy);
Assert.AreEqual(0, wrapper.ConsecutiveCrashCount);
}
[TestMethod]
public void Identity_DerivesFromManifest()
{
var wrapper = CreateWrapper();
Assert.AreEqual("js!test-ext", wrapper.ExtensionUniqueId);
Assert.AreEqual("js!test-ext", wrapper.PackageFamilyName);
Assert.AreEqual("Test Extension", wrapper.ExtensionDisplayName);
Assert.AreEqual("unit-test", wrapper.Publisher);
Assert.IsTrue(wrapper.HasProviderType(ProviderType.Commands));
}
[TestMethod]
public void Version_ParsesManifestVersion()
{
var wrapper = CreateWrapper("4.5.6");
var version = wrapper.Version;
Assert.AreEqual(4, version.Major);
Assert.AreEqual(5, version.Minor);
Assert.AreEqual(6, version.Build);
}
[TestMethod]
public void Version_MissingVersion_DefaultsToOneZeroZero()
{
var wrapper = CreateWrapper(version: null);
var version = wrapper.Version;
Assert.AreEqual(1, version.Major);
Assert.AreEqual(0, version.Minor);
Assert.AreEqual(0, version.Build);
}
[TestMethod]
public void GetExtensionObject_IsNull_ForJsExtensions()
{
var wrapper = CreateWrapper();
Assert.IsNull(wrapper.GetExtensionObject());
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the stable list-item identity used to reuse item adapters across a
/// refresh (p4-03). Keying by the SDK-emitted stable id (or nested command id) rather
/// than the title keeps each row bound to its own command when a refresh reorders
/// items that share a title, so a duplicate-title reorder does not swap actions.
/// </summary>
[TestClass]
public class JSListItemAdapterKeyTests
{
private static string Key(string json)
{
using var doc = JsonDocument.Parse(json);
return JSListItemAdapter.ComputeKey(doc.RootElement);
}
[TestMethod]
public void ComputeKey_PrefersStableId()
{
Assert.AreEqual("id:item-42", Key("""{ "id": "item-42", "title": "Anything" }"""));
}
[TestMethod]
public void ComputeKey_FallsBackToCommandId()
{
Assert.AreEqual("cmd:cmd-7", Key("""{ "title": "Anything", "command": { "id": "cmd-7" } }"""));
}
[TestMethod]
public void ComputeKey_FallsBackToTitleWhenNoId()
{
Assert.AreEqual("title:Only Title", Key("""{ "title": "Only Title" }"""));
}
[TestMethod]
public void ComputeKey_IdIsNamespacedApartFromTitle()
{
// An id "X" must not produce the same key as a title "X".
var idKey = Key("""{ "id": "X" }""");
var titleKey = Key("""{ "title": "X" }""");
Assert.AreNotEqual(idKey, titleKey);
}
[TestMethod]
public void ComputeKey_DuplicateTitlesWithDistinctIds_ProduceDistinctKeys()
{
// The duplicate-title reorder scenario: two rows share a title but have their
// own ids, so they get distinct keys and stay bound to their own commands.
var first = Key("""{ "id": "a", "title": "Same Title" }""");
var second = Key("""{ "id": "b", "title": "Same Title" }""");
Assert.AreNotEqual(first, second);
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Documents the crash-lifecycle decisions behind r2-p4-03 (an immediate post-init
/// crash must be handled) and r2-p4-07 (a crash-disabled extension recovers after a
/// source edit). The end-to-end wiring (the post-init <c>IsRunning()</c> probe that
/// drives <c>OnExtensionProcessExited</c>, the disable branch keeping the source
/// watcher alive, and hot-reload resetting the crash count) requires spawning a Node
/// process and is verified by inspection; the deterministic decision the wiring relies
/// on is exercised here through the pure <see cref="JsonRpcExtensionService.DecideCrashAction"/>
/// seam.
/// </summary>
[TestClass]
public class JsonRpcExtensionServiceCrashRecoveryTests
{
private const int MaxRestartAttempts = 3;
[TestMethod]
public void CrashSequence_ReachesDisableAfterExceedingLimit()
{
// Each recorded crash increments the count; the service restarts while at or below
// the limit and disables only once the count exceeds it. A crash observed
// immediately after init (p4-03) feeds this same counter, so an extension that
// exits right after starting is not treated as healthy.
var crashCount = 0;
for (var attempt = 1; attempt <= MaxRestartAttempts; attempt++)
{
crashCount++;
Assert.AreEqual(
JsonRpcExtensionService.CrashAction.Restart,
JsonRpcExtensionService.DecideCrashAction(crashCount, MaxRestartAttempts),
$"Crash {crashCount} is within the limit and must restart.");
}
crashCount++;
Assert.AreEqual(
JsonRpcExtensionService.CrashAction.Disable,
JsonRpcExtensionService.DecideCrashAction(crashCount, MaxRestartAttempts),
"Exceeding the restart limit must disable the extension.");
}
[TestMethod]
public void SourceEdit_ResetsCrashCount_AllowsRestartAgain()
{
// Drive the extension to the disabled decision.
var crashCount = MaxRestartAttempts + 1;
Assert.AreEqual(
JsonRpcExtensionService.CrashAction.Disable,
JsonRpcExtensionService.DecideCrashAction(crashCount, MaxRestartAttempts));
// A source edit hot-reloads with resetCrashCount: true, which clears the counter
// for the directory. The very next crash decision must be Restart again, so the
// extension is no longer stranded in the disabled state (p4-07).
crashCount = 0;
crashCount++;
Assert.AreEqual(
JsonRpcExtensionService.CrashAction.Restart,
JsonRpcExtensionService.DecideCrashAction(crashCount, MaxRestartAttempts),
"After a source edit resets the crash count, the extension must retry loading.");
}
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class JsonRpcExtensionServiceDiscoveryTests
{
private string _root = null!;
[TestInitialize]
public void Setup()
{
_root = Path.Combine(Path.GetTempPath(), $"JSExtDiscovery_{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[TestMethod]
public void DiscoverManifests_ReturnsOnlyValidCmdPalExtensions()
{
// Valid: has cmdpal section + resolvable entry point.
const string GoodJson = """
{
"name": "good-ext",
"main": "index.js",
"cmdpal": { "displayName": "Good" }
}
""";
CreateExtension("good", GoodJson, "index.js");
// Invalid: no cmdpal section.
const string NoCmdPalJson = """
{
"name": "plain-ext",
"main": "index.js"
}
""";
CreateExtension("no-cmdpal", NoCmdPalJson, "index.js");
// Invalid: cmdpal section but entry point does not exist.
const string MissingEntryJson = """
{
"name": "missing-entry-ext",
"main": "index.js",
"cmdpal": {}
}
""";
CreateExtension("missing-entry", MissingEntryJson, entryPointRelativePath: null);
// Invalid: directory without a package.json at all.
Directory.CreateDirectory(Path.Combine(_root, "empty"));
var results = JsonRpcExtensionService.DiscoverManifests(_root);
Assert.AreEqual(1, results.Count);
Assert.AreEqual("good-ext", results[0].Manifest.Name);
Assert.AreEqual(Path.Combine(_root, "good"), results[0].Directory);
}
[TestMethod]
public void DiscoverManifests_MissingRoot_ReturnsEmpty()
{
var results = JsonRpcExtensionService.DiscoverManifests(Path.Combine(_root, "does-not-exist"));
Assert.AreEqual(0, results.Count);
}
[TestMethod]
public void DiscoverManifests_EmptyRoot_ReturnsEmpty()
{
var results = JsonRpcExtensionService.DiscoverManifests(_root);
Assert.AreEqual(0, results.Count);
}
[TestMethod]
public void DecideCrashAction_AtOrBelowLimit_Restarts()
{
Assert.AreEqual(JsonRpcExtensionService.CrashAction.Restart, JsonRpcExtensionService.DecideCrashAction(1, 3));
Assert.AreEqual(JsonRpcExtensionService.CrashAction.Restart, JsonRpcExtensionService.DecideCrashAction(2, 3));
Assert.AreEqual(JsonRpcExtensionService.CrashAction.Restart, JsonRpcExtensionService.DecideCrashAction(3, 3));
}
[TestMethod]
public void DecideCrashAction_AboveLimit_Disables()
{
Assert.AreEqual(JsonRpcExtensionService.CrashAction.Disable, JsonRpcExtensionService.DecideCrashAction(4, 3));
Assert.AreEqual(JsonRpcExtensionService.CrashAction.Disable, JsonRpcExtensionService.DecideCrashAction(10, 3));
}
[TestMethod]
public void IsUnderDirectory_SamePath_IsTrue()
{
var dir = Path.Combine(_root, "foo");
Assert.IsTrue(JsonRpcExtensionService.IsUnderDirectory(dir, dir));
Assert.IsTrue(JsonRpcExtensionService.IsUnderDirectory(dir + Path.DirectorySeparatorChar, dir));
}
[TestMethod]
public void IsUnderDirectory_Descendant_IsTrue()
{
var dir = Path.Combine(_root, "foo");
var file = Path.Combine(dir, "src", "index.js");
Assert.IsTrue(JsonRpcExtensionService.IsUnderDirectory(file, dir));
}
[TestMethod]
public void IsUnderDirectory_SiblingWithSharedPrefix_IsFalse()
{
// "foo-bar" must not be considered a child of "foo".
var dir = Path.Combine(_root, "foo");
var sibling = Path.Combine(_root, "foo-bar", "index.js");
Assert.IsFalse(JsonRpcExtensionService.IsUnderDirectory(sibling, dir));
}
[TestMethod]
public void IsUnderDirectory_EmptyArguments_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.IsUnderDirectory(string.Empty, _root));
Assert.IsFalse(JsonRpcExtensionService.IsUnderDirectory(_root, string.Empty));
}
private void CreateExtension(string dirName, string packageJson, string? entryPointRelativePath)
{
var dir = Path.Combine(_root, dirName);
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "package.json"), packageJson);
if (entryPointRelativePath is not null)
{
var entryPath = Path.Combine(dir, entryPointRelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
File.WriteAllText(entryPath, "// entry");
}
}
}

View File

@@ -0,0 +1,202 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Covers the robust-discovery and duplicate-id remediations (p4-04, p4-07): the
/// reconciliation diff, the deterministic collision policy, the manifest-stability
/// retry, and mapping a changed path back to its owning extension directory.
/// </summary>
[TestClass]
public class JsonRpcExtensionServiceReconciliationTests
{
private string _root = null!;
[TestInitialize]
public void Setup()
{
_root = Path.Combine(Path.GetTempPath(), $"JSExtReconcile_{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[TestMethod]
public void ReconcileDirectories_ComputesAddsAndRemoves()
{
var discovered = new[]
{
@"C:\ext\alpha",
@"C:\ext\beta",
@"C:\ext\gamma",
};
var loaded = new[]
{
@"C:\ext\beta\", // trailing separator, still the same directory
@"C:\ext\DELTA", // loaded but no longer on disk
};
var (toAdd, toRemove) = JsonRpcExtensionService.ReconcileDirectories(discovered, loaded);
var addSet = toAdd.Select(DirectoryLifecycleGate.Canonicalize).ToHashSet(StringComparer.OrdinalIgnoreCase);
var removeSet = toRemove.Select(DirectoryLifecycleGate.Canonicalize).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.IsTrue(addSet.Contains(DirectoryLifecycleGate.Canonicalize(@"C:\ext\alpha")));
Assert.IsTrue(addSet.Contains(DirectoryLifecycleGate.Canonicalize(@"C:\ext\gamma")));
Assert.IsFalse(addSet.Contains(DirectoryLifecycleGate.Canonicalize(@"C:\ext\beta")), "Already-loaded beta is not re-added.");
Assert.AreEqual(2, addSet.Count);
Assert.IsTrue(removeSet.Contains(DirectoryLifecycleGate.Canonicalize(@"C:\ext\DELTA")), "A loaded-but-deleted extension is reconciled out.");
Assert.AreEqual(1, removeSet.Count);
}
[TestMethod]
public void ResolveIdCollisions_DuplicateIds_DeterministicWinnerByPath()
{
// Two extensions in different directories advertise the same name key.
CreateExtension("z-dir", "dup-ext");
CreateExtension("a-dir", "dup-ext");
CreateExtension("solo", "unique-ext");
var discovered = JsonRpcExtensionService.DiscoverManifests(_root);
var (accepted, rejected) = JsonRpcExtensionService.ResolveIdCollisions(discovered);
// The winner is deterministic: the canonical-path-sorted first directory wins,
// independent of enumeration order. "a-dir" sorts before "z-dir".
var acceptedDirs = accepted.Select(a => Path.GetFileName(a.Directory)).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.IsTrue(acceptedDirs.Contains("a-dir"), "The path-sorted first duplicate wins.");
Assert.IsTrue(acceptedDirs.Contains("solo"), "A non-duplicate is always accepted.");
Assert.IsFalse(acceptedDirs.Contains("z-dir"), "The losing duplicate is rejected.");
Assert.AreEqual(1, rejected.Count);
Assert.AreEqual("z-dir", Path.GetFileName(rejected[0].Directory));
Assert.AreEqual(
DirectoryLifecycleGate.Canonicalize(Path.Combine(_root, "a-dir")),
rejected[0].WinnerDirectory,
"The rejection records the deterministic winner directory.");
}
[TestMethod]
public void ResolveIdCollisions_IsStableAcrossInputOrder()
{
CreateExtension("z-dir", "dup-ext");
CreateExtension("a-dir", "dup-ext");
var discovered = JsonRpcExtensionService.DiscoverManifests(_root).ToList();
var forward = JsonRpcExtensionService.ResolveIdCollisions(discovered);
discovered.Reverse();
var reversed = JsonRpcExtensionService.ResolveIdCollisions(discovered);
var forwardWinner = Path.GetFileName(forward.Accepted.Single().Directory);
var reversedWinner = Path.GetFileName(reversed.Accepted.Single().Directory);
Assert.AreEqual("a-dir", forwardWinner);
Assert.AreEqual(forwardWinner, reversedWinner, "The winner is independent of input order.");
}
[TestMethod]
public async Task WaitForStableManifestAsync_RetriesUntilManifestParses()
{
var attempts = 0;
JSExtensionManifestParseResult Parse(string manifestPath)
{
attempts++;
// Simulate a slow install: the first two reads see a partially written
// package that does not parse, the third read succeeds.
if (attempts < 3)
{
return JSExtensionManifestParseResult.Failure("still being written");
}
return JSExtensionManifestParseResult.Success(new JSExtensionManifest { Name = "slow-ext" });
}
var delays = 0;
Task Delay(int attempt, CancellationToken token)
{
delays++;
return Task.CompletedTask;
}
var manifest = await JsonRpcExtensionService.WaitForStableManifestAsync(
"package.json",
attempts: 5,
Parse,
Delay,
CancellationToken.None);
Assert.IsNotNull(manifest);
Assert.AreEqual("slow-ext", manifest!.Name);
Assert.AreEqual(3, attempts);
Assert.AreEqual(2, delays, "It waited between the failed attempts.");
}
[TestMethod]
public async Task WaitForStableManifestAsync_NeverValid_ReturnsNull()
{
var manifest = await JsonRpcExtensionService.WaitForStableManifestAsync(
"package.json",
attempts: 3,
_ => JSExtensionManifestParseResult.Failure("bad"),
(_, _) => Task.CompletedTask,
CancellationToken.None);
Assert.IsNull(manifest);
}
[TestMethod]
public void GetExtensionDirectoryForPath_ReturnsOwningTopLevelDirectory()
{
var manifestPath = Path.Combine(_root, "my-ext", "package.json");
var sourcePath = Path.Combine(_root, "my-ext", "src", "index.js");
var expected = Path.Combine(Path.GetFullPath(_root), "my-ext");
Assert.AreEqual(expected, JsonRpcExtensionService.GetExtensionDirectoryForPath(_root, manifestPath));
Assert.AreEqual(expected, JsonRpcExtensionService.GetExtensionDirectoryForPath(_root, sourcePath));
}
[TestMethod]
public void GetExtensionDirectoryForPath_PathOutsideRoot_ReturnsNull()
{
Assert.IsNull(JsonRpcExtensionService.GetExtensionDirectoryForPath(_root, @"C:\somewhere\else\file.js"));
Assert.IsNull(JsonRpcExtensionService.GetExtensionDirectoryForPath(_root, _root));
}
private void CreateExtension(string dirName, string extensionName)
{
var dir = Path.Combine(_root, dirName);
Directory.CreateDirectory(dir);
var packageJson = $$"""
{
"name": "{{extensionName}}",
"main": "index.js",
"cmdpal": { "displayName": "{{extensionName}}" }
}
""";
File.WriteAllText(Path.Combine(dir, "package.json"), packageJson);
File.WriteAllText(Path.Combine(dir, "index.js"), "// entry");
}
}

View File

@@ -0,0 +1,235 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.IO;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the pure watcher-routing decisions used by the directory and source
/// watchers (r2-p4-04, r2-p4-05, r2-p4-06). These are the same decisions the live
/// watchers make, extracted so they can be tested without spinning up a real
/// FileSystemWatcher or a Node process:
/// <list type="bullet">
/// <item>Churn under node_modules/.git subtrees must not trigger reloads (p4-04).</item>
/// <item>Rename/delete-derived source paths still route to a reload (p4-05).</item>
/// <item>A manifest edit is detectable so an explicit refresh reloads it (p4-06).</item>
/// </list>
/// </summary>
[TestClass]
public class JsonRpcExtensionServiceWatcherRoutingTests
{
private static string Path3(string a, string b, string c) => Path.Combine(a, b, c);
[TestMethod]
public void HasIgnoredDirectorySegment_NodeModules_IsTrue()
{
var path = Path.Combine(@"C:\ext\my-extension", "node_modules", "left-pad", "index.js");
Assert.IsTrue(JsonRpcExtensionService.HasIgnoredDirectorySegment(path));
}
[TestMethod]
public void HasIgnoredDirectorySegment_NestedNodeModules_IsTrue()
{
// A deeply nested node_modules tree (the restart-storm source) must still be caught.
var path = Path.Combine(
@"C:\ext\my-extension",
"node_modules",
"a",
"node_modules",
"b",
"package.json");
Assert.IsTrue(JsonRpcExtensionService.HasIgnoredDirectorySegment(path));
}
[TestMethod]
public void HasIgnoredDirectorySegment_GitFolder_IsTrue()
{
var path = Path3(@"C:\ext\my-extension", ".git", "index");
Assert.IsTrue(JsonRpcExtensionService.HasIgnoredDirectorySegment(path));
}
[TestMethod]
public void HasIgnoredDirectorySegment_SimilarlyNamedFolder_IsFalse()
{
// A directory whose name merely contains "node_modules" is not the real thing.
var path = Path3(@"C:\ext\my-extension", "node_modules_backup", "index.js");
Assert.IsFalse(JsonRpcExtensionService.HasIgnoredDirectorySegment(path));
var git = Path3(@"C:\ext\my-extension", "gitignore-samples", "index.js");
Assert.IsFalse(JsonRpcExtensionService.HasIgnoredDirectorySegment(git));
}
[TestMethod]
public void HasIgnoredDirectorySegment_ForwardSlashes_AreHonored()
{
Assert.IsTrue(JsonRpcExtensionService.HasIgnoredDirectorySegment("C:/ext/my-extension/node_modules/pkg/index.js"));
}
[TestMethod]
public void HasIgnoredDirectorySegment_Empty_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.HasIgnoredDirectorySegment(string.Empty));
Assert.IsFalse(JsonRpcExtensionService.HasIgnoredDirectorySegment(null!));
}
[TestMethod]
public void ShouldReloadForSourceChange_JavaScriptSource_IsTrue()
{
// A plain source edit, a rename target, and a delete all arrive as full paths;
// each must route to a reload.
Assert.IsTrue(JsonRpcExtensionService.ShouldReloadForSourceChange(Path3(@"C:\ext\my-extension", "src", "index.js")));
Assert.IsTrue(JsonRpcExtensionService.ShouldReloadForSourceChange(Path.Combine(@"C:\ext\my-extension", "commands.mjs")));
Assert.IsTrue(JsonRpcExtensionService.ShouldReloadForSourceChange(Path.Combine(@"C:\ext\my-extension", "legacy.cjs")));
}
[TestMethod]
public void ShouldReloadForSourceChange_UnderNodeModules_IsFalse()
{
// Even though it is a .js file, a change under node_modules must never reload.
var path = Path.Combine(@"C:\ext\my-extension", "node_modules", "dep", "index.js");
Assert.IsFalse(JsonRpcExtensionService.ShouldReloadForSourceChange(path));
}
[TestMethod]
public void ShouldReloadForSourceChange_NonSourceFile_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.ShouldReloadForSourceChange(Path.Combine(@"C:\ext\my-extension", "README.md")));
Assert.IsFalse(JsonRpcExtensionService.ShouldReloadForSourceChange(Path.Combine(@"C:\ext\my-extension", "styles.css")));
}
[TestMethod]
public void ShouldReloadForSourceChange_Empty_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.ShouldReloadForSourceChange(string.Empty));
Assert.IsFalse(JsonRpcExtensionService.ShouldReloadForSourceChange(null!));
}
[TestMethod]
public void ManifestChanged_IdenticalManifests_IsFalse()
{
var manifest = SampleManifest();
Assert.IsFalse(JsonRpcExtensionService.ManifestChanged(manifest, manifest with { }));
}
[TestMethod]
public void ManifestChanged_DisplayNameEdited_IsTrue()
{
var loaded = SampleManifest();
var current = loaded with { DisplayName = "Renamed Extension" };
Assert.IsTrue(JsonRpcExtensionService.ManifestChanged(loaded, current));
}
[TestMethod]
public void ManifestChanged_VersionEdited_IsTrue()
{
var loaded = SampleManifest();
Assert.IsTrue(JsonRpcExtensionService.ManifestChanged(loaded, loaded with { Version = "2.0.0" }));
}
[TestMethod]
public void ManifestChanged_EntryPointEdited_IsTrue()
{
var loaded = SampleManifest();
var current = loaded with { EntryPointPath = @"C:\ext\my-extension\dist\index.js" };
Assert.IsTrue(JsonRpcExtensionService.ManifestChanged(loaded, current));
}
[TestMethod]
public void ManifestChanged_DebugToggled_IsTrue()
{
var loaded = SampleManifest();
Assert.IsTrue(JsonRpcExtensionService.ManifestChanged(loaded, loaded with { Debug = true }));
Assert.IsTrue(JsonRpcExtensionService.ManifestChanged(loaded, loaded with { DebugPort = 9333 }));
}
[TestMethod]
public void ManifestChanged_NullOperand_IsFalse()
{
var loaded = SampleManifest();
Assert.IsFalse(JsonRpcExtensionService.ManifestChanged(null!, loaded));
Assert.IsFalse(JsonRpcExtensionService.ManifestChanged(loaded, null!));
}
// r3-p4-02: the recursive root watcher reports every descendant path. Only a top-level
// <root>/<extdir> directory or its own <root>/<extdir>/package.json manifest is an
// extension entry; anything deeper (a nested package or a node_modules manifest) must be
// ignored so a nested package.json is not treated as an extension upsert.
[TestMethod]
public void IsTopLevelExtensionChange_ExtensionDirectory_IsTrue()
{
Assert.IsTrue(JsonRpcExtensionService.IsTopLevelExtensionChange(@"C:\root", @"C:\root\my-extension"));
}
[TestMethod]
public void IsTopLevelExtensionChange_TopLevelManifest_IsTrue()
{
Assert.IsTrue(JsonRpcExtensionService.IsTopLevelExtensionChange(@"C:\root", @"C:\root\my-extension\package.json"));
}
[TestMethod]
public void IsTopLevelExtensionChange_NestedManifest_IsFalse()
{
// A package.json inside a nested package or under node_modules is two-plus levels
// below the extension directory and is not an extension entry.
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(
@"C:\root", Path.Combine(@"C:\root", "my-extension", "node_modules", "dep", "package.json")));
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(
@"C:\root", Path.Combine(@"C:\root", "my-extension", "packages", "inner", "package.json")));
}
[TestMethod]
public void IsTopLevelExtensionChange_NestedDirectory_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(
@"C:\root", Path.Combine(@"C:\root", "my-extension", "dist")));
}
[TestMethod]
public void IsTopLevelExtensionChange_NonManifestTopLevelFile_IsFalse()
{
// A file that sits at <root>/<extdir>/<file> but is not the manifest is not an entry.
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(
@"C:\root", Path.Combine(@"C:\root", "my-extension", "index.js")));
}
[TestMethod]
public void IsTopLevelExtensionChange_OutsideRoot_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(@"C:\root", @"C:\other\my-extension\package.json"));
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(@"C:\root", @"C:\root"));
}
[TestMethod]
public void IsTopLevelExtensionChange_ForwardSlashesAndCasing_AreHonored()
{
Assert.IsTrue(JsonRpcExtensionService.IsTopLevelExtensionChange("C:/root", "C:/root/my-extension/Package.json"));
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange("C:/root", "C:/root/my-extension/node_modules/dep/package.json"));
}
[TestMethod]
public void IsTopLevelExtensionChange_Empty_IsFalse()
{
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(string.Empty, @"C:\root\ext"));
Assert.IsFalse(JsonRpcExtensionService.IsTopLevelExtensionChange(@"C:\root", string.Empty));
}
private static JSExtensionManifest SampleManifest() => new()
{
Name = "my-extension",
DisplayName = "My Extension",
Version = "1.0.0",
Description = "A sample extension.",
Icon = "\uE700",
Publisher = "Contoso",
Main = "index.js",
EntryPointPath = @"C:\ext\my-extension\index.js",
Debug = false,
DebugPort = null,
};
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class NodeRuntimeLocatorTests
{
private static readonly string[] MalformedPathEntries = { "invalid|path" };
[TestMethod]
public void ResolveNodeExecutable_ReturnsFirstDirectoryThatContainsNodeExe()
{
var root = Path.Combine(Path.GetTempPath(), "cmdpal-node-locator-" + Guid.NewGuid().ToString("N"));
var withoutNode = Path.Combine(root, "without");
var withNode = Path.Combine(root, "with");
Directory.CreateDirectory(withoutNode);
Directory.CreateDirectory(withNode);
var expected = Path.Combine(withNode, "node.exe");
try
{
File.WriteAllText(expected, string.Empty);
// The first directory has no node.exe, so resolution must skip it and return
// the absolute path from the second directory rather than the bare name.
var resolved = NodeRuntimeLocator.ResolveNodeExecutable(new[] { withoutNode, withNode });
Assert.AreEqual(expected, resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[TestMethod]
public void ResolveNodeExecutable_ReturnsNullWhenNodeExeIsNotPresent()
{
var root = Path.Combine(Path.GetTempPath(), "cmdpal-node-locator-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var resolved = NodeRuntimeLocator.ResolveNodeExecutable(new[] { root });
Assert.IsNull(resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[TestMethod]
public void ResolveNodeExecutable_SkipsMalformedPathEntries()
{
// A PATH entry containing invalid path characters must be skipped rather than
// throwing, so a single bad entry cannot break node.exe resolution.
var resolved = NodeRuntimeLocator.ResolveNodeExecutable(MalformedPathEntries);
Assert.IsNull(resolved);
}
[TestMethod]
public void ResolveNodeExecutable_ReturnsNullForEmptyDirectoryList()
{
Assert.IsNull(NodeRuntimeLocator.ResolveNodeExecutable(Array.Empty<string>()));
}
}

View File

@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the atomic provider-id reservation registry (r2-p4-01). A provider id can be
/// owned by exactly one directory at a time regardless of how many registration paths
/// race for it (full scan, hot reload, or concurrent install), and it is freed only by
/// its owner so a stale release cannot steal an id that was reclaimed by someone else.
/// </summary>
[TestClass]
public class ProviderIdReservationsTests
{
[TestMethod]
public void TryReserve_FreeId_Succeeds()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
}
[TestMethod]
public void TryReserve_SameDirectoryAgain_IsIdempotent()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
// The same owner re-reserving its own id must succeed (a hot-reload re-registers
// the same directory). Callers pass canonical keys, which compare case-insensitively
// to match the rest of the service's directory comparisons.
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
Assert.IsTrue(reservations.TryReserve("alpha", @"c:\ext\A"));
}
[TestMethod]
public void TryReserve_DifferentDirectorySameId_Fails()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
Assert.IsFalse(reservations.TryReserve("alpha", @"C:\ext\b"));
}
[TestMethod]
public void TryReserve_EmptyId_AlwaysSucceedsAndReservesNothing()
{
var reservations = new ProviderIdReservations();
// An extension with no name key has nothing to collide on; two different
// directories can both "reserve" an empty id.
Assert.IsTrue(reservations.TryReserve(string.Empty, @"C:\ext\a"));
Assert.IsTrue(reservations.TryReserve(string.Empty, @"C:\ext\b"));
Assert.IsTrue(reservations.TryReserve(null, @"C:\ext\c"));
}
[TestMethod]
public void Release_ByOwner_FreesId()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
reservations.Release("alpha", @"C:\ext\a");
// Now a different directory may claim it.
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\b"));
}
[TestMethod]
public void Release_ByNonOwner_DoesNotFreeId()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
// A stale release from a directory that does not own the id must be ignored, so
// the real owner keeps it.
reservations.Release("alpha", @"C:\ext\b");
Assert.IsFalse(reservations.TryReserve("alpha", @"C:\ext\b"));
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
}
[TestMethod]
public void Clear_ReleasesEverything()
{
var reservations = new ProviderIdReservations();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\a"));
Assert.IsTrue(reservations.TryReserve("beta", @"C:\ext\b"));
reservations.Clear();
Assert.IsTrue(reservations.TryReserve("alpha", @"C:\ext\x"));
Assert.IsTrue(reservations.TryReserve("beta", @"C:\ext\y"));
}
[TestMethod]
public async Task TryReserve_ConcurrentDifferentDirectories_ExactlyOneWins()
{
var reservations = new ProviderIdReservations();
const int Contenders = 32;
using var start = new ManualResetEventSlim(false);
var winners = new ConcurrentBag<int>();
var tasks = new Task[Contenders];
for (var i = 0; i < Contenders; i++)
{
var index = i;
tasks[i] = Task.Run(() =>
{
// Every contender blocks on the same gate so they all race the reservation
// at once, simulating a full scan, a hot reload, and a concurrent install
// all claiming the same id.
start.Wait();
if (reservations.TryReserve("shared-id", $@"C:\ext\dir-{index}"))
{
winners.Add(index);
}
});
}
start.Set();
await Task.WhenAll(tasks);
Assert.AreEqual(1, winners.Count, "Exactly one directory may claim a provider id, regardless of the registration path.");
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// An <see cref="IExtensionHost"/> that records every status and log call in arrival
/// order and lets a test await a specific number of calls without polling. Used by the
/// proxy startup-notification and status-lifecycle tests.
/// </summary>
internal sealed partial class RecordingExtensionHost : IExtensionHost
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly Lock _gate = new();
private readonly List<IStatusMessage> _shown = [];
private readonly List<IStatusMessage> _hidden = [];
private readonly List<ILogMessage> _logs = [];
private readonly List<(int Target, TaskCompletionSource Signal)> _shownWaiters = [];
private readonly List<(int Target, TaskCompletionSource Signal)> _hiddenWaiters = [];
private readonly List<(int Target, TaskCompletionSource Signal)> _logWaiters = [];
public IReadOnlyList<IStatusMessage> Shown
{
get
{
lock (_gate)
{
return [.. _shown];
}
}
}
public IReadOnlyList<IStatusMessage> Hidden
{
get
{
lock (_gate)
{
return [.. _hidden];
}
}
}
public IReadOnlyList<ILogMessage> Logs
{
get
{
lock (_gate)
{
return [.. _logs];
}
}
}
public Task WaitForShownCountAsync(int count) => WaitFor(_shown, _shownWaiters, count);
public Task WaitForHiddenCountAsync(int count) => WaitFor(_hidden, _hiddenWaiters, count);
public Task WaitForLogCountAsync(int count) => WaitFor(_logs, _logWaiters, count);
public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
{
if (message is not null)
{
Record(_shown, _shownWaiters, message);
}
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction HideStatus(IStatusMessage? message)
{
if (message is not null)
{
Record(_hidden, _hiddenWaiters, message);
}
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction LogMessage(ILogMessage? message)
{
if (message is not null)
{
Record(_logs, _logWaiters, message);
}
return Task.CompletedTask.AsAsyncAction();
}
private void Record<T>(List<T> sink, List<(int Target, TaskCompletionSource Signal)> waiters, T value)
{
List<TaskCompletionSource> ready = [];
lock (_gate)
{
sink.Add(value);
var current = sink.Count;
for (var i = waiters.Count - 1; i >= 0; i--)
{
if (current >= waiters[i].Target)
{
ready.Add(waiters[i].Signal);
waiters.RemoveAt(i);
}
}
}
foreach (var signal in ready)
{
signal.TrySetResult();
}
}
private Task WaitFor<T>(List<T> sink, List<(int Target, TaskCompletionSource Signal)> waiters, int count)
{
TaskCompletionSource signal;
lock (_gate)
{
if (sink.Count >= count)
{
return Task.CompletedTask;
}
signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
waiters.Add((count, signal));
}
return signal.Task.WaitAsync(DefaultTimeout);
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the replaceable reload cancellation source (p4-01). A single
/// CancellationTokenSource can only cancel once, so a service that shares one token
/// between stop and a later reload would keep handing out an already canceled token.
/// These tests confirm a fresh, uncanceled token is available after a stop.
/// </summary>
[TestClass]
public class ReloadCancellationTests
{
[TestMethod]
public void BeginCycle_AfterStop_YieldsFreshUncanceledToken()
{
using var reload = new ReloadCancellation();
var first = reload.BeginCycle();
Assert.IsFalse(first.IsCancellationRequested, "A new cycle should start uncanceled.");
reload.Stop();
Assert.IsTrue(reload.IsStopRequested, "A stop should be observable.");
Assert.IsTrue(first.IsCancellationRequested, "In-flight token should observe the stop.");
// The load-stop-load sequence: a second cycle must produce a live token so
// providers load on the second load instead of silently doing nothing.
var second = reload.BeginCycle();
Assert.IsFalse(second.IsCancellationRequested, "The second load cycle should get a live token.");
Assert.IsFalse(reload.IsStopRequested, "The wrapper should no longer report a stop after a new cycle.");
}
[TestMethod]
public void Token_AfterDispose_IsCanceledAndDoesNotThrow()
{
var reload = new ReloadCancellation();
reload.BeginCycle();
reload.Dispose();
Assert.IsTrue(reload.IsStopRequested);
Assert.IsTrue(reload.Token.IsCancellationRequested);
// Begin after dispose returns a canceled token rather than throwing.
Assert.IsTrue(reload.BeginCycle().IsCancellationRequested);
// Dispose is idempotent.
reload.Dispose();
}
[TestMethod]
public void Stop_WithoutBeginCycle_StillCancelsCurrentToken()
{
using var reload = new ReloadCancellation();
var token = reload.Token;
reload.Stop();
Assert.IsTrue(token.IsCancellationRequested);
Assert.IsTrue(reload.IsStopRequested);
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies the single ordered dispatch path for provider add/remove notifications
/// (r3-p4-04). Every emission runs on one worker in strict first-in-first-out order, so a
/// consumer can never observe a provider addition ahead of the removal enqueued before it,
/// even when the two originate on different threads.
/// </summary>
[TestClass]
public class SerialNotificationDispatcherTests
{
[TestMethod]
public void Enqueue_RunsNotificationsInFifoOrder()
{
using var dispatcher = new SerialNotificationDispatcher();
var observed = new ConcurrentQueue<int>();
var done = new CountdownEvent(500);
for (var i = 0; i < 500; i++)
{
var value = i;
dispatcher.Enqueue(() =>
{
observed.Enqueue(value);
done.Signal();
});
}
Assert.IsTrue(done.Wait(TimeSpan.FromSeconds(5)), "All notifications should have run.");
var expected = 0;
foreach (var value in observed)
{
Assert.AreEqual(expected, value, "Notifications must run in enqueue order.");
expected++;
}
Assert.AreEqual(500, expected);
}
// A paired removal enqueued ahead of an addition must always be observed first, even
// when the two are enqueued from different threads racing each other.
[TestMethod]
public void Enqueue_FromConcurrentThreads_PreservesPerCallerOrder()
{
using var dispatcher = new SerialNotificationDispatcher();
var removeBeforeAdd = true;
var addSeen = false;
var done = new CountdownEvent(200);
for (var i = 0; i < 100; i++)
{
// Each iteration enqueues a "remove" then an "add" from the same caller. The
// add handler must never run before its paired remove handler.
var removed = false;
dispatcher.Enqueue(() =>
{
removed = true;
done.Signal();
});
dispatcher.Enqueue(() =>
{
if (!removed)
{
removeBeforeAdd = false;
}
addSeen = true;
done.Signal();
});
}
Assert.IsTrue(done.Wait(TimeSpan.FromSeconds(5)), "All notifications should have run.");
Assert.IsTrue(addSeen);
Assert.IsTrue(removeBeforeAdd, "An addition must never overtake the removal enqueued before it.");
}
[TestMethod]
public void Enqueue_AfterDispose_IsDroppedSilently()
{
var dispatcher = new SerialNotificationDispatcher();
dispatcher.Dispose();
var ran = false;
dispatcher.Enqueue(() => ran = true);
Thread.Sleep(100);
Assert.IsFalse(ran, "A notification enqueued after dispose must not run.");
}
[TestMethod]
public void Dispose_DrainsAlreadyEnqueuedNotifications()
{
var dispatcher = new SerialNotificationDispatcher();
var count = 0;
for (var i = 0; i < 50; i++)
{
dispatcher.Enqueue(() => Interlocked.Increment(ref count));
}
dispatcher.Dispose();
Assert.AreEqual(50, Volatile.Read(ref count), "Dispose must let already-queued notifications drain.");
}
[TestMethod]
public void Enqueue_HandlerException_DoesNotStopLaterNotifications()
{
using var dispatcher = new SerialNotificationDispatcher();
var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
dispatcher.Enqueue(() => throw new InvalidOperationException("boom"));
dispatcher.Enqueue(() => reached.TrySetResult());
Assert.IsTrue(reached.Task.Wait(TimeSpan.FromSeconds(5)), "A throwing handler must not stall the worker.");
}
}