From 7ea57795fc3c81144c7837039b533aaa7d81dc77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:10:37 +0000 Subject: [PATCH 1/2] improve: FrozenDictionary command dispatch map in WindowsNodeClient Replace O(n) linear capability scans with O(1) FrozenDictionary lookup. - Add _commandMap field (FrozenDictionary) - 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> --- src/OpenClaw.Shared/WindowsNodeClient.cs | 24 ++- .../WindowsNodeClientTests.cs | 152 ++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 693dbf1..9e7fdfd 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -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 _capabilities = new(); + private FrozenDictionary _commandMap = FrozenDictionary.Empty; private readonly NodeRegistration _registration; // Connection state @@ -100,9 +101,26 @@ public void RegisterCapability(INodeCapability capability) } } + // 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)"); } + /// + /// Builds a FrozenDictionary mapping each command name to the capability that owns it. + /// First-registered capability wins on collision (matching the former FirstOrDefault semantics). + /// + private FrozenDictionary BuildCommandMap() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cap in _capabilities) + foreach (var cmd in cap.Commands) + map.TryAdd(cmd, cap); + return map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + /// /// Set a permission for the node /// @@ -351,7 +369,7 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) }; // 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 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) }; // Find capability that can handle this command - var capability = _capabilities.FirstOrDefault(c => c.CanHandle(command)); + var capability = _commandMap.GetValueOrDefault(command); if (capability == null) { diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index b2ed5b5..d9713d1 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -1021,4 +1021,156 @@ private static async Task InvokeHandleEventAsync(WindowsNodeClient client, strin 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 Commands => _commands; + public bool CanHandle(string command) => Array.IndexOf(_commands, command) >= 0; + + public Task 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; + } } From 9b5e2adfb659b6ddb47ab1b78deed3cf970c1071 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 23 Apr 2026 10:45:21 -0700 Subject: [PATCH 2/2] improve: FrozenDictionary command dispatch map in WindowsNodeClient Replace two O(n) FirstOrDefault(c => c.CanHandle(command)) scans with O(1) FrozenDictionary lookup. TryAdd preserves first-registered- wins semantics. Map rebuilt on each RegisterCapability() call (startup only, before ConnectAsync). Add event-path dispatch test to cover HandleNodeInvokeEventAsync in addition to the request-path tests. Based on Repo Assist PR #197, with additional test coverage. Closes #197 --- .../WindowsNodeClientTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index d9713d1..77ac93c 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -1164,6 +1164,44 @@ public async Task CommandDispatch_FirstRegisteredCapabilityWins_ForDuplicateComm } } + [Fact] + public async Task CommandDispatch_EventPath_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"); + client.RegisterCapability(cap); + + // Use "type": "event" wire format (HandleNodeInvokeEventAsync path) + var json = """ + { + "type": "event", + "event": "node.invoke.request", + "payload": { + "requestId": "inv-evt-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); + } + } + private static async Task InvokeProcessMessageAsync(WindowsNodeClient client, string json) { var processMethod = typeof(WindowsNodeClient).GetMethod(