improve: FrozenDictionary command dispatch map in WindowsNodeClient

Replace O(n) linear capability scans with O(1) FrozenDictionary lookup.

- Add _commandMap field (FrozenDictionary<string, INodeCapability>)
- Add BuildCommandMap() to (re)build the map after each RegisterCapability call
- Replace both FirstOrDefault dispatch calls with _commandMap.GetValueOrDefault
- First-registered capability wins on command collision (preserves original semantics)
- Add 3 tests: routing, unknown command, first-registered-wins collision

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-04-22 13:10:37 +00:00 committed by GitHub
parent 9134be49e1
commit c23ffcdae8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 173 additions and 3 deletions

View File

@ -1,6 +1,6 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
@ -18,6 +18,7 @@ public class WindowsNodeClient : WebSocketClientBase
// Node capabilities registry
private readonly List<INodeCapability> _capabilities = new();
private FrozenDictionary<string, INodeCapability> _commandMap = FrozenDictionary<string, INodeCapability>.Empty;
private readonly NodeRegistration _registration;
// Connection state
@ -100,9 +101,26 @@ public class WindowsNodeClient : WebSocketClientBase
}
}
// Rebuild the O(1) command dispatch map so node.invoke lookups stay fast
// regardless of how many capabilities or commands are registered.
_commandMap = BuildCommandMap();
_logger.Info($"Registered capability: {capability.Category} ({capability.Commands.Count} commands)");
}
/// <summary>
/// Builds a FrozenDictionary mapping each command name to the capability that owns it.
/// First-registered capability wins on collision (matching the former FirstOrDefault semantics).
/// </summary>
private FrozenDictionary<string, INodeCapability> BuildCommandMap()
{
var map = new Dictionary<string, INodeCapability>(StringComparer.OrdinalIgnoreCase);
foreach (var cap in _capabilities)
foreach (var cmd in cap.Commands)
map.TryAdd(cmd, cap);
return map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Set a permission for the node
/// </summary>
@ -351,7 +369,7 @@ public class WindowsNodeClient : WebSocketClientBase
};
// Find capability that can handle this command
var capability = _capabilities.FirstOrDefault(c => c.CanHandle(command));
var capability = _commandMap.GetValueOrDefault(command);
if (capability == null)
{
@ -762,7 +780,7 @@ public class WindowsNodeClient : WebSocketClientBase
};
// Find capability that can handle this command
var capability = _capabilities.FirstOrDefault(c => c.CanHandle(command));
var capability = _commandMap.GetValueOrDefault(command);
if (capability == null)
{

View File

@ -1021,4 +1021,156 @@ public class WindowsNodeClientTests
Assert.NotNull(task);
await task!;
}
// ─── Command dispatch map tests ────────────────────────────────────────────
private sealed class MockCapability : INodeCapability
{
private readonly string _category;
private readonly string[] _commands;
public int ExecuteCount { get; private set; }
public string? LastCommand { get; private set; }
public MockCapability(string category, params string[] commands)
{
_category = category;
_commands = commands;
}
public string Category => _category;
public IReadOnlyList<string> Commands => _commands;
public bool CanHandle(string command) => Array.IndexOf(_commands, command) >= 0;
public Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
{
ExecuteCount++;
LastCommand = request.Command;
return Task.FromResult(new NodeInvokeResponse { Id = request.Id, Ok = true, Payload = new { dispatched = true } });
}
}
[Fact]
public async Task CommandDispatch_RoutesToRegisteredCapability()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var cap = new MockCapability("mock", "mock.ping", "mock.echo");
client.RegisterCapability(cap);
var json = """
{
"type": "req",
"id": "req-1",
"method": "node.invoke",
"params": {
"requestId": "inv-1",
"command": "mock.ping",
"args": {}
}
}
""";
await InvokeProcessMessageAsync(client, json);
Assert.Equal(1, cap.ExecuteCount);
Assert.Equal("mock.ping", cap.LastCommand);
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
[Fact]
public async Task CommandDispatch_UnknownCommand_DoesNotInvokeAnyCapability()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var cap = new MockCapability("mock", "mock.ping");
client.RegisterCapability(cap);
var json = """
{
"type": "req",
"id": "req-2",
"method": "node.invoke",
"params": {
"requestId": "inv-2",
"command": "unknown.command",
"args": {}
}
}
""";
await InvokeProcessMessageAsync(client, json);
Assert.Equal(0, cap.ExecuteCount);
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
[Fact]
public async Task CommandDispatch_FirstRegisteredCapabilityWins_ForDuplicateCommand()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var first = new MockCapability("cat-a", "shared.command");
var second = new MockCapability("cat-b", "shared.command");
client.RegisterCapability(first);
client.RegisterCapability(second);
var json = """
{
"type": "req",
"id": "req-3",
"method": "node.invoke",
"params": {
"requestId": "inv-3",
"command": "shared.command",
"args": {}
}
}
""";
await InvokeProcessMessageAsync(client, json);
Assert.Equal(1, first.ExecuteCount);
Assert.Equal(0, second.ExecuteCount);
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
private static async Task InvokeProcessMessageAsync(WindowsNodeClient client, string json)
{
var processMethod = typeof(WindowsNodeClient).GetMethod(
"ProcessMessageAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(processMethod);
var task = (Task)processMethod!.Invoke(client, [json])!;
await task;
}
}