mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
[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:
committed by
Michael Jolley
parent
8aadbe1bd0
commit
f7c1a4fc93
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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><manifestDirectory>/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user