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:
parent
9134be49e1
commit
c23ffcdae8
@ -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)
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user