diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..be5b33e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+# Tests every push and pull request; on a push, also builds the setup and uploads it as an artifact,
+# to download from the run's Summary page.
+
+name: CI
+
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+
+# A newer push to the same branch or PR supersedes a run still in progress.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+
+jobs:
+ test:
+ name: Test
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install Rust
+ run: rustup toolchain install stable --profile minimal && rustup default stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src/rust
+
+ - name: Rust tests
+ working-directory: src/rust
+ run: cargo test --workspace --locked
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '10.0.4xx'
+
+ # The controller has no tests of its own; building it (warnings are errors) is the check.
+ - name: Build controller
+ working-directory: src/csharp
+ run: dotnet build VirtualKmSwitch.slnx -c Release
+
+ installer:
+ name: Installer
+ if: github.event_name != 'pull_request'
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install Rust
+ run: rustup toolchain install stable --profile minimal && rustup default stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src/rust
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '10.0.4xx'
+
+ - name: Build setup
+ run: powershell -ExecutionPolicy Bypass -File installer\build.ps1
+
+ - name: Short commit id
+ id: sha
+ shell: bash
+ run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: VirtualKmSwitch-${{ steps.sha.outputs.short }}-x64
+ path: |
+ target/installer/*-setup.exe
+ target/installer/*.msi
+ if-no-files-found: error
+ retention-days: 30
diff --git a/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs b/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs
deleted file mode 100644
index 32248a3..0000000
--- a/src/csharp/bench/VirtualKmSwitch.Benchmarks/Program.cs
+++ /dev/null
@@ -1 +0,0 @@
-Console.WriteLine("Virtual KM Switch benchmarks (not yet implemented).");
diff --git a/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj b/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj
deleted file mode 100644
index 81beb80..0000000
--- a/src/csharp/bench/VirtualKmSwitch.Benchmarks/VirtualKmSwitch.Benchmarks.csproj
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
- Exe
- false
- true
-
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs b/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs
deleted file mode 100644
index f800af2..0000000
--- a/src/csharp/spike/VirtualKmSwitch.Spike/Program.cs
+++ /dev/null
@@ -1,417 +0,0 @@
-using System.Diagnostics;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.InputEngine;
-using VirtualKmSwitch.Interop;
-
-namespace VirtualKmSwitch.Spike;
-
-///
-/// Verifies replay end to end on this machine: does a calibrated mouse delta actually travel the
-/// distance it asked for, and does scancode keyboard replay reach a real application?
-///
-/// The mouse half is self-checking -- it injects and then measures the cursor -- because the whole
-/// point of calibration is that the number requested and the number travelled agree, and that is
-/// measurable without a human. It is compared against uncalibrated injection so the correction is
-/// visible rather than merely asserted.
-///
-internal static class Program
-{
- private static int Main(string[] args)
- {
- // Mode selection comes first: the verification path probes by moving the cursor, which
- // has no business running when the caller asked for something else.
- if (args.Contains("--control", StringComparer.OrdinalIgnoreCase))
- {
- return QueryServiceAsync().GetAwaiter().GetResult();
- }
-
- if (args.Contains("--engage", StringComparer.OrdinalIgnoreCase))
- {
- return RunEngagementDemo();
- }
-
- if (args.Contains("--tray", StringComparer.OrdinalIgnoreCase))
- {
- return RunTrayCheck();
- }
-
- Console.WriteLine("Virtual KM Switch replay verification");
- Console.WriteLine(new string('=', 66));
-
- (int t1, int t2, int accel, int speed) = PointerSettings.Read();
- var ballistics = new PointerBallistics(t1, t2, accel, speed);
-
- Console.WriteLine($"this machine: threshold1={t1} threshold2={t2} acceleration={accel} speed={speed}");
- Console.WriteLine($"\"Enhance pointer precision\": {(accel != 0 ? "ON" : "OFF")}"
- + (accel != 0 ? " (chunking active)" : " (no acceleration curve; chunking disabled)"));
- Console.WriteLine($"speed multiplier: {ballistics.SpeedMultiplier:0.###}x, "
- + $"max unaccelerated chunk: {ballistics.MaxUnacceleratedChunk}");
- Console.WriteLine();
-
- Console.WriteLine("Probing this machine's actual response (the cursor will move briefly)...");
- var probeInjector = new InputInjector();
- MouseCorrectionCurve correction = MouseCalibrationProbe.Measure(probeInjector, ballistics);
- Console.WriteLine($"measured correction curve across {correction.SampleCount} magnitudes: "
- + $"10px={correction.FactorFor(10):0.###}x 50px={correction.FactorFor(50):0.###}x "
- + $"200px={correction.FactorFor(200):0.###}x");
- Console.WriteLine();
-
- int failures = 0;
- failures += VerifyMouseTravel(ballistics, correction);
-
- if (args.Contains("--keyboard", StringComparer.OrdinalIgnoreCase))
- {
- VerifyKeyboardReplay();
- }
- else
- {
- Console.WriteLine();
- Console.WriteLine("Skipping the keyboard check (needs a focused Notepad).");
- Console.WriteLine("Run with --keyboard to include it.");
- }
-
- failures += VerifyHookHealth();
-
- Console.WriteLine();
- Console.WriteLine("Watching pointer settings for 6s -- change the speed slider or toggle");
- Console.WriteLine("\"Enhance pointer precision\" now to see it detected.");
-
- using (var monitor = new PointerSettingsMonitor(
- changed => Console.WriteLine($" CHANGE DETECTED: speed={changed.Speed} "
- + $"acceleration={changed.Acceleration} "
- + $"(pointer precision {(changed.IsPointerPrecisionEnabled ? "ON" : "OFF")}) "
- + "-- calibration is now stale and would be re-probed"),
- TimeSpan.FromSeconds(1)))
- {
- Thread.Sleep(TimeSpan.FromSeconds(6));
- }
-
- Console.WriteLine();
- Console.WriteLine(new string('=', 66));
- Console.WriteLine(failures == 0 ? "RESULT: replay verified." : $"RESULT: {failures} check(s) failed.");
- return failures;
- }
-
- ///
- /// Talks to the running service over the control pipe, as an ordinary unelevated user.
- ///
- /// This is the cross-privilege check: a LocalSystem service and a normal-user client, with the
- /// two-tier authorisation deciding what the caller may do from its own token rather than from
- /// anything it claims.
- ///
- private static async Task QueryServiceAsync()
- {
- Console.WriteLine("Control pipe check (running unelevated)");
- Console.WriteLine(new string('=', 66));
-
- await using var client = new VirtualKmSwitch.Ipc.ControlPipeClient();
-
- try
- {
- await client.ConnectAsync(TimeSpan.FromSeconds(5));
- Console.WriteLine("connected to the service");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"could not connect: {ex.Message}");
- Console.WriteLine("Is the service installed and running?");
- return 1;
- }
-
- Console.WriteLine();
- Console.WriteLine("-- runtime-tier verb (should succeed) --");
- VirtualKmSwitch.Ipc.ControlResponse status = await client.SendAsync(
- new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.GetStatus });
-
- Console.WriteLine($" success: {status.Success}");
- if (status.Values is not null)
- {
- foreach ((string key, string value) in status.Values)
- {
- Console.WriteLine($" {key} = {value}");
- }
- }
-
- Console.WriteLine();
- Console.WriteLine("-- membership (runtime tier: reading who is a member is not privileged) --");
- VirtualKmSwitch.Ipc.ControlResponse machines = await client.SendAsync(
- new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.GetMachines });
-
- VirtualKmSwitch.Ipc.ClusterView? view = VirtualKmSwitch.Ipc.ClusterView.FromResponse(machines);
- if (view is null)
- {
- Console.WriteLine($" no view returned: {machines.Error}");
- }
- else
- {
- Console.WriteLine($" cluster : {(view.HasCluster ? $"'{view.ClusterName}' ({view.ClusterId})" : "none")}");
- Console.WriteLine($" this machine: {view.LocalNodeId}");
- Console.WriteLine($" health : {view.Health}");
- Console.WriteLine($" join key : {(view.JoinKey is null ? "not disclosed" : "DISCLOSED -- it should not be")}");
-
- foreach (VirtualKmSwitch.Ipc.MachineInfo machine in view.Machines)
- {
- Console.WriteLine($" {machine.Label}{(machine.IsLocal ? " (this machine)" : string.Empty)}"
- + $" {machine.NodeId:N}");
- }
- }
-
- Console.WriteLine();
- Console.WriteLine("-- administrative verb (should be refused unless elevated) --");
- VirtualKmSwitch.Ipc.ControlResponse enroll = await client.SendAsync(
- new VirtualKmSwitch.Ipc.ControlRequest { Verb = VirtualKmSwitch.Ipc.ControlVerb.EnrollPeer });
-
- Console.WriteLine($" success: {enroll.Success}");
- Console.WriteLine($" error : {enroll.Error}");
-
- Console.WriteLine();
- Console.WriteLine(new string('=', 66));
- return status.Success ? 0 : 1;
- }
-
- ///
- /// Puts a real icon in the notification area and reports what the user does with it.
- ///
- /// Shell_NotifyIcon failing is the one thing here that cannot be inferred from a build, and
- /// the controller deliberately swallows that failure rather than refusing to start — so this
- /// is where it is allowed to be loud.
- ///
- private static int RunTrayCheck()
- {
- Console.WriteLine("Virtual KM Switch tray icon check");
- Console.WriteLine(new string('=', 66));
-
- TrayIcon icon;
- try
- {
- icon = new TrayIcon(
- "Virtual KM Switch spike",
- [
- new TrayMenuItem(1, "Say hello"),
- TrayMenuItem.Separator,
- new TrayMenuItem(2, "Quit"),
- ]);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"FAILED to add the icon: {ex.Message}");
- return 1;
- }
-
- Console.WriteLine("icon added. Look in the notification area (it may be under the overflow arrow).");
- Console.WriteLine(" left-click -> reports activation");
- Console.WriteLine(" right-click -> shows the menu");
- Console.WriteLine();
-
- using var finished = new ManualResetEventSlim(false);
- using (icon)
- {
- icon.Activated += () => Console.WriteLine("activated (left click)");
- icon.CommandInvoked += command =>
- {
- Console.WriteLine($"menu command {command} chosen");
- if (command == 2)
- {
- finished.Set();
- }
- };
-
- Console.WriteLine("waiting up to 60s; choose Quit from the menu to finish early.");
- finished.Wait(TimeSpan.FromSeconds(60));
- }
-
- Console.WriteLine("icon removed.");
- return 0;
- }
-
- ///
- /// Drives the full engagement path by hand: hotkeys, blocking, capture and panic.
- ///
- /// Nothing is sent anywhere -- captured events are printed -- so this shows exactly what would
- /// have gone on the wire, and what the local machine was prevented from seeing.
- ///
- private static int RunEngagementDemo()
- {
- long captured = 0;
-
- Console.WriteLine("Engagement demo -- 30 seconds.");
- Console.WriteLine();
- Console.WriteLine(" Ctrl+Alt+1 engage (keyboard and mouse go dead locally)");
- Console.WriteLine(" Ctrl+Alt+0 return control");
- Console.WriteLine(" Ctrl+Alt+Shift+Esc panic");
- Console.WriteLine();
- Console.WriteLine("While engaged, typing and mouse movement are captured and shown here");
- Console.WriteLine("instead of reaching the desktop.");
- Console.WriteLine();
-
- // A hard backstop: blocking must never outlive this process, whatever else goes wrong.
- var watchdog = new Thread(() =>
- {
- Thread.Sleep(TimeSpan.FromSeconds(40));
- Environment.Exit(99);
- })
- { IsBackground = true };
- watchdog.Start();
-
- using var controller = new EngagementController(
- sink: (in InputEvent e) =>
- {
- long total = Interlocked.Increment(ref captured);
-
- // Only keys and buttons are printed: motion at kilohertz rates would bury them.
- if (e.Kind != InputEventKind.MouseMove && total < 200)
- {
- Console.WriteLine($" -> {e.Kind} code={e.Code} flags={e.Flags}");
- }
- },
- log: message => Console.WriteLine($" [{message}]"));
-
- controller.StateChanged += (state, slot) =>
- Console.WriteLine($" STATE: {state}{(slot > 0 ? $" -> slot {slot}" : string.Empty)}");
- controller.HostClaimRequested += () => Console.WriteLine(" HOST CLAIM requested");
- controller.PanicTriggered += () => Console.WriteLine(" PANIC handled");
-
- Thread.Sleep(TimeSpan.FromSeconds(30));
-
- Console.WriteLine();
- Console.WriteLine($" captured {Interlocked.Read(ref captured)} events while engaged");
- Console.WriteLine($" final state: {controller.State}");
- return 0;
- }
-
- ///
- /// Confirms the watchdog can actually tell a live hook from a dead one. A liveness probe that
- /// always answers "alive" is worse than no watchdog at all, so both directions are checked.
- ///
- private static int VerifyHookHealth()
- {
- Console.WriteLine();
- Console.WriteLine("Hook liveness watchdog:");
-
- var injector = new InputInjector();
- using var hook = new LowLevelHook();
- using var monitor = new HookHealthMonitor(hook, injector, log: m => Console.WriteLine($" {m}"));
-
- HookProbeResult alive = monitor.Probe();
- Console.WriteLine($" hook installed : {alive}");
-
- // Disposing the hook is the closest available stand-in for Windows silently removing it.
- hook.Dispose();
- HookProbeResult dead = monitor.Probe();
- Console.WriteLine($" hook disposed : {dead}");
-
- bool aliveDetected = alive == HookProbeResult.Alive;
- bool deadDetected = dead == HookProbeResult.Dead;
-
- bool ok = aliveDetected && deadDetected;
- Console.WriteLine($" => watchdog {(ok ? "distinguishes live from dead" : "IS NOT RELIABLE")}");
- return ok ? 0 : 1;
- }
-
- private static int VerifyMouseTravel(PointerBallistics ballistics, MouseCorrectionCurve correction)
- {
- var injector = new InputInjector();
- var calibration = new MouseCalibration(ballistics) { Correction = correction };
-
- (int originX, int originY) = Cursor.GetPosition();
-
- Console.WriteLine("Injecting horizontal moves and measuring actual cursor travel.");
- Console.WriteLine();
- Console.WriteLine(" requested | uncalibrated | calibrated | calibrated error");
- Console.WriteLine(" " + new string('-', 60));
-
- int failures = 0;
-
- foreach (int requested in new[] { 10, 25, 50, 100, 200, 400 })
- {
- int uncalibrated = MeasureUncalibrated(injector, requested);
-
- calibration.Reset();
- int calibrated = MeasureCalibrated(injector, calibration, requested);
-
- int error = calibrated - requested;
-
- // A little slack: the measurement races the input pipeline, and the cursor clamps at
- // screen edges.
- bool ok = Math.Abs(error) <= Math.Max(2, requested / 20);
- if (!ok)
- {
- failures++;
- }
-
- Console.WriteLine($" {requested,9} | {uncalibrated,12} | {calibrated,10} | "
- + $"{error,4} {(ok ? "ok" : "FAIL")}");
- }
-
- Cursor.SetPosition(originX, originY);
- return failures;
- }
-
- private static int MeasureUncalibrated(InputInjector injector, int requested)
- {
- int start = BeginMeasure();
-
- Span single = stackalloc InputEvent[1];
- single[0] = InputEvent.MouseMove(Stopwatch.GetTimestamp(), requested, 0);
- injector.Inject(single);
-
- return EndMeasure(start);
- }
-
- private static int MeasureCalibrated(InputInjector injector, MouseCalibration calibration, int requested)
- {
- int start = BeginMeasure();
-
- Span chunks = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
- int count = calibration.Prepare(Stopwatch.GetTimestamp(), requested, 0, chunks);
- injector.Inject(chunks[..count]);
-
- return EndMeasure(start);
- }
-
- /// Parks the cursor well away from any screen edge, which would clamp travel.
- private static int BeginMeasure()
- {
- Cursor.SetPosition(400, 400);
- Thread.Sleep(60);
- return Cursor.GetPosition().X;
- }
-
- private static int EndMeasure(int start)
- {
- Thread.Sleep(120); // let the input pipeline settle
- return Cursor.GetPosition().X - start;
- }
-
- private static void VerifyKeyboardReplay()
- {
- Console.WriteLine();
- Console.WriteLine("Keyboard replay: focus a Notepad window within 5 seconds...");
- for (int i = 5; i > 0; i--)
- {
- Console.Write($"{i}... ");
- Thread.Sleep(1000);
- }
-
- Console.WriteLine();
-
- var injector = new InputInjector();
-
- // Scancodes for "virtual ok" typed on a US layout. Scancodes rather than virtual keys
- // is the whole point: the target reproduces the physical key, not a layout-dependent
- // character.
- ushort[] scanCodes = [0x2F, 0x17, 0x13, 0x14, 0x16, 0x1E, 0x26, 0x39, 0x18, 0x25];
-
- Span batch = stackalloc InputEvent[2];
- foreach (ushort scanCode in scanCodes)
- {
- long now = Stopwatch.GetTimestamp();
- batch[0] = InputEvent.Key(now, scanCode, InputEventFlags.None);
- batch[1] = InputEvent.Key(now, scanCode, InputEventFlags.Up);
- injector.Inject(batch);
- Thread.Sleep(30);
- }
-
- Console.WriteLine(" Injected. Check whether the text appeared in Notepad.");
- }
-}
diff --git a/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj b/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj
deleted file mode 100644
index dd4013d..0000000
--- a/src/csharp/spike/VirtualKmSwitch.Spike/VirtualKmSwitch.Spike.csproj
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
- Exe
- false
- true
-
-
-
-
-
-
-
-
-
-
diff --git a/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs b/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs
deleted file mode 100644
index fb80229..0000000
--- a/src/csharp/spike/VirtualKmSwitch.UacSpike/Program.cs
+++ /dev/null
@@ -1,308 +0,0 @@
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Interop;
-
-namespace VirtualKmSwitch.UacSpike;
-
-///
-/// Proves (or disproves) the premise of the three-tier process model: that a LocalSystem service
-/// can place a helper inside the interactive session, and that the helper can follow the input
-/// desktop onto Winlogon and inject there -- i.e. drive a live UAC prompt.
-///
-/// Two modes in one executable:
-/// --service : runs as LocalSystem in session 0, spawns the agent into the console session.
-/// --agent : runs as SYSTEM inside that session, follows the desktop, injects on Winlogon.
-///
-/// Neither mode has a console, so both log to a file.
-///
-/// The injected key is ESCAPE, which *dismisses* a UAC prompt. Choosing Escape rather than
-/// Enter keeps the spike from approving an elevation it did not intend to: if the prompt
-/// disappears, injection into the secure desktop demonstrably works, and nothing was granted.
-///
-internal static class Program
-{
- private static readonly string LogDirectory =
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "VirtualKmSwitch");
-
- private static readonly string _logPath = Path.Combine(LogDirectory, "uac-spike.log");
-
- /// Used if ProgramData is not writable by whatever identity we ended up running as.
- private static readonly string FallbackLogPath =
- Path.Combine(Path.GetTempPath(), "vkms-uac-spike.log");
- private static readonly Lock LogGate = new();
-
- private static int Main(string[] args)
- {
- // Logged before anything else can fail. Under Task Scheduler there is no console, so a
- // process that starts and then exits down some other path is indistinguishable from one
- // that never started at all -- unless it says so here first.
- Log($"=== process start === argc={args.Length} args=[{string.Join(" | ", args)}] "
- + $"cwd={Environment.CurrentDirectory}");
-
- string mode = args.FirstOrDefault() ?? "--help";
-
- return mode switch
- {
- "--service" => RunService(),
- "--agent" => RunAgent(),
- "--agent-secure" => RunSecureDesktopAgent(),
- "--tail" => TailLog(),
- _ => ShowHelp(),
- };
- }
-
- private static int ShowHelp()
- {
- Console.WriteLine("Virtual KM Switch UAC spike");
- Console.WriteLine();
- Console.WriteLine(" --service run as LocalSystem (session 0); spawns the agent");
- Console.WriteLine(" --agent run inside the console session as SYSTEM");
- Console.WriteLine(" --tail print the log written by the other two modes");
- Console.WriteLine();
- Console.WriteLine($"Log: {_logPath}");
- return 1;
- }
-
- private static int TailLog()
- {
- if (!File.Exists(_logPath))
- {
- Console.WriteLine($"No log yet at {_logPath}");
- return 1;
- }
-
- foreach (string line in File.ReadAllLines(_logPath))
- {
- Console.WriteLine(line);
- }
-
- return 0;
- }
-
- // ---------------------------------------------------------------------------------------
- // Service side: session 0. Cannot inject anything itself; its whole job is placing the agent.
- // ---------------------------------------------------------------------------------------
- private static int RunService()
- {
- Log("=== service starting ===");
- Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}");
-
- try
- {
- uint session = SessionLauncher.GetActiveConsoleSession();
- Log($"active console session: {session}");
-
- if (session == SessionLauncher.NoSession)
- {
- Log("no interactive session attached; nothing to do");
- return 1;
- }
-
- string executable = Environment.ProcessPath
- ?? throw new InvalidOperationException("Cannot determine own executable path.");
-
- uint defaultPid = SessionLauncher.LaunchAsSystemInSession(
- executable, "--agent", session, @"Winsta0\Default");
- Log($"spawned Default-desktop agent as SYSTEM in session {session}, pid {defaultPid}");
-
- // The interesting one: created directly on the secure desktop rather than migrating
- // onto it later. SetThreadDesktop moved a thread there successfully but SendInput was
- // still denied, which points at the process's own desktop association mattering.
- try
- {
- uint securePid = SessionLauncher.LaunchAsSystemInSession(
- executable, "--agent-secure", session, @"Winsta0\Winlogon");
- Log($"spawned Winlogon-desktop agent as SYSTEM in session {session}, pid {securePid}");
- }
- catch (Exception ex)
- {
- Log($"could not spawn agent on Winlogon desktop: {ex.Message}");
- }
-
- return 0;
- }
- catch (Exception ex)
- {
- Log($"SERVICE FAILED: {ex}");
- return 2;
- }
- }
-
- // ---------------------------------------------------------------------------------------
- // Agent side: inside the interactive session, as SYSTEM. This is the half that can inject.
- // ---------------------------------------------------------------------------------------
- private static int RunAgent()
- {
- Log("=== agent starting ===");
- Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}");
- using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent())
- {
- Log($"is SYSTEM: {identity.IsSystem}, sid: {identity.User}");
- }
-
- var injector = new InputInjector();
- int secureDesktopVisits = 0;
-
- try
- {
- using var watcher = new DesktopWatcher(
- onDesktopChanged: name =>
- {
- Log($"input desktop is now '{name}'");
-
- if (!string.Equals(name, "Winlogon", StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- secureDesktopVisits++;
- Log($"SECURE DESKTOP reached (visit {secureDesktopVisits}) -- injecting ESC");
-
- // Injection must happen on the thread that owns the desktop attachment,
- // which is exactly the thread this callback runs on.
- long now = Stopwatch.GetTimestamp();
- Span escape =
- [
- InputEvent.Key(now, 0x01, InputEventFlags.None),
- InputEvent.Key(now, 0x01, InputEventFlags.Up),
- ];
-
- uint accepted = injector.Inject(escape);
- int error = Marshal.GetLastWin32Error();
-
- // SendInput reports UIPI blocking neither in its return value nor in
- // GetLastError, so 'accepted' is necessary but not sufficient evidence.
- // Whether the prompt actually closed is the real result.
- Log($" SendInput accepted {accepted}/2 events (last error {error})");
- },
- log: Log);
-
- Log("watching for desktop switches for 120s -- trigger a UAC prompt now");
-
- for (int i = 0; i < 120; i++)
- {
- Thread.Sleep(1000);
- }
-
- Log($"=== agent finished; secure desktop reached {secureDesktopVisits} time(s) ===");
- return secureDesktopVisits > 0 ? 0 : 1;
- }
- catch (Exception ex)
- {
- Log($"AGENT FAILED: {ex}");
- return 2;
- }
- }
-
- ///
- /// Runs already attached to the Winlogon desktop, having been created there. It therefore never
- /// calls SetThreadDesktop -- doing so is what the previous attempt did, and injection was
- /// denied. It only watches for its own desktop becoming the active input desktop.
- ///
- private static int RunSecureDesktopAgent()
- {
- Log("=== secure-desktop agent starting ===");
- Log($"identity: {Environment.UserName}, session: {Process.GetCurrentProcess().SessionId}");
-
- using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent())
- {
- Log($"is SYSTEM: {identity.IsSystem}");
- }
-
- var injector = new InputInjector();
- string previous = string.Empty;
- int injections = 0;
-
- try
- {
- for (int tick = 0; tick < 1200; tick++)
- {
- string current = DesktopWatcher.GetInputDesktopName();
-
- if (current != previous)
- {
- Log($"input desktop is now '{current}'");
- previous = current;
-
- if (string.Equals(current, "Winlogon", StringComparison.OrdinalIgnoreCase))
- {
- injections++;
- Log($"SECURE DESKTOP active (visit {injections}) -- running injection sequence");
-
- // The desktop switches BEFORE consent.exe has created and focused its
- // dialog, so injecting immediately can land before anything exists to
- // receive it. Several attempts are spread over a few seconds, and both
- // encodings are tried, so a null result means "rejected" rather than
- // "mistimed" or "wrong encoding".
- InjectAfter(injector, 750, "scancode", scanCode: true);
- InjectAfter(injector, 750, "virtual-key", scanCode: false);
- InjectAfter(injector, 1000, "scancode (late)", scanCode: true);
- InjectAfter(injector, 1000, "virtual-key (late)", scanCode: false);
- }
- }
-
- Thread.Sleep(100);
- }
-
- Log($"=== secure agent finished; {injections} injection attempt(s) ===");
- return 0;
- }
- catch (Exception ex)
- {
- Log($"SECURE AGENT FAILED: {ex}");
- return 2;
- }
- }
-
- ///
- /// Waits, then injects Escape once, reporting exactly what SendInput said.
- ///
- private static void InjectAfter(InputInjector injector, int delayMilliseconds, string label, bool scanCode)
- {
- Thread.Sleep(delayMilliseconds);
-
- const ushort EscapeScanCode = 0x01;
- const ushort VkEscape = 0x1B;
-
- long now = Stopwatch.GetTimestamp();
- Span escape = scanCode
- ?
- [
- InputEvent.Key(now, EscapeScanCode, InputEventFlags.None),
- InputEvent.Key(now, EscapeScanCode, InputEventFlags.Up),
- ]
- :
- [
- InputEvent.Key(now, VkEscape, InputEventFlags.VirtualKey),
- InputEvent.Key(now, VkEscape, InputEventFlags.VirtualKey | InputEventFlags.Up),
- ];
-
- uint accepted = injector.Inject(escape);
- int error = Marshal.GetLastWin32Error();
- Log($" [{label}] on '{DesktopWatcher.GetInputDesktopName()}': "
- + $"SendInput accepted {accepted}/2 (last error {error})");
- }
-
- private static void Log(string message)
- {
- string line = $"{DateTime.Now:HH:mm:ss.fff} [{Environment.ProcessId,6}] {message}";
- lock (LogGate)
- {
- foreach (string path in new[] { _logPath, FallbackLogPath })
- {
- try
- {
- Directory.CreateDirectory(Path.GetDirectoryName(path)!);
- File.AppendAllText(path, line + Environment.NewLine);
- return;
- }
- catch
- {
- // Fall through to the next candidate. A spike must never die because logging
- // failed, but it must also not fail *silently* -- hence the second location.
- }
- }
- }
- }
-}
diff --git a/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj b/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj
deleted file mode 100644
index 1d61e01..0000000
--- a/src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
- Exe
- false
- true
- VirtualKmSwitch.UacSpike
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/csharp/spike/run-uac-spike.ps1 b/src/csharp/spike/run-uac-spike.ps1
deleted file mode 100644
index d1a61e1..0000000
--- a/src/csharp/spike/run-uac-spike.ps1
+++ /dev/null
@@ -1,117 +0,0 @@
-#Requires -RunAsAdministrator
-<#
- Runs the UAC / secure-desktop spike.
-
- The service half must run in session 0 to reproduce the real constraint, so it is launched as
- SYSTEM via a scheduled task -- the same isolation a Windows service gets, without the SCM
- plumbing, which belongs to the real implementation rather than to a spike.
-
- Everything created here is removed again at the end.
-#>
-
-$ErrorActionPreference = 'Stop'
-
-$taskName = 'VirtualKmSwitchUacSpike'
-$logPath = Join-Path $env:ProgramData 'VirtualKmSwitch\uac-spike.log'
-$exePath = Join-Path $PSScriptRoot 'VirtualKmSwitch.UacSpike\bin\Debug\net10.0-windows10.0.19041.0\VirtualKmSwitch.UacSpike.exe'
-
-function Remove-SpikeTask {
- Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
-}
-
-function Stop-SpikeProcesses {
- Get-Process -Name 'VirtualKmSwitch.UacSpike' -ErrorAction SilentlyContinue |
- Stop-Process -Force -ErrorAction SilentlyContinue
-}
-
-if (-not (Test-Path $exePath)) {
- Write-Host 'Not built. Run:' -ForegroundColor Red
- Write-Host ' dotnet build src/csharp/spike/VirtualKmSwitch.UacSpike/VirtualKmSwitch.UacSpike.csproj'
- Write-Host "Expected: $exePath"
- exit 1
-}
-
-Write-Host 'Virtual KM Switch UAC spike' -ForegroundColor Cyan
-Write-Host ('=' * 62)
-Write-Host "exe : $exePath"
-Write-Host "log : $logPath"
-Write-Host ''
-
-# Start from a clean log so the result is unambiguous.
-if (Test-Path $logPath) { Remove-Item $logPath -Force -ErrorAction SilentlyContinue }
-
-Remove-SpikeTask
-Stop-SpikeProcesses
-
-try {
- Write-Host 'Registering SYSTEM task (runs in session 0)...' -NoNewline
- $action = New-ScheduledTaskAction -Execute $exePath -Argument '--service'
- $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
-
- # AllowStartIfOnBatteries is essential, not cosmetic: the default is to REFUSE to start on
- # battery, and the task then sits in Queued forever with no error and no process -- which
- # looks identical to the launch itself having failed.
- $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
-
- $null = Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force
- Write-Host ' done'
-
- Write-Host 'Starting it...' -NoNewline
- Start-ScheduledTask -TaskName $taskName
- Write-Host ' done'
-
- Start-Sleep -Seconds 4
-
- $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
- $taskState = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State
- Remove-SpikeTask
-
- $agent = Get-Process -Name 'VirtualKmSwitch.UacSpike' -ErrorAction SilentlyContinue
- if (-not $agent) {
- Write-Host ''
- Write-Host 'The agent is NOT running: the service half could not place it in the session.' -ForegroundColor Red
- Write-Host ''
- if ($taskInfo) {
- Write-Host (" task state : {0}" -f $taskState)
- Write-Host (" last run time : {0}" -f $taskInfo.LastRunTime)
- Write-Host (" last task result : 0x{0:X}" -f $taskInfo.LastTaskResult)
- if ($taskState -eq 'Queued') {
- Write-Host ' (Queued means Task Scheduler never ran it -- a conditions problem, not a code one.)' -ForegroundColor Yellow
- }
- }
- Write-Host ''
- Write-Host 'Log:' -ForegroundColor Yellow
- if (Test-Path $logPath) { Get-Content $logPath } else { Write-Host ' (no log written -- the process never started)' }
- exit 1
- }
-
- Write-Host ''
- Write-Host ('=' * 62) -ForegroundColor Green
- Write-Host ' AGENT IS RUNNING AS SYSTEM IN YOUR SESSION' -ForegroundColor Green
- Write-Host ('=' * 62) -ForegroundColor Green
- Write-Host ''
- Write-Host ' Now TRIGGER A UAC PROMPT. From a NORMAL (non-admin) PowerShell:' -ForegroundColor Yellow
- Write-Host ''
- Write-Host ' Start-Process notepad -Verb RunAs' -ForegroundColor White
- Write-Host ''
- Write-Host ' The agent injects ESCAPE on reaching the secure desktop, which DISMISSES the' -ForegroundColor Yellow
- Write-Host ' prompt rather than approving it.' -ForegroundColor Yellow
- Write-Host ''
- Write-Host ' WATCH FOR: does the UAC prompt close by itself?' -ForegroundColor Cyan
- Write-Host ''
-
- for ($i = 60; $i -gt 0; $i -= 5) {
- Write-Host " ${i}s remaining" -ForegroundColor DarkGray
- Start-Sleep -Seconds 5
- }
-}
-finally {
- Remove-SpikeTask
- Stop-SpikeProcesses
-}
-
-Write-Host ''
-Write-Host ('=' * 62)
-Write-Host 'LOG' -ForegroundColor Cyan
-Write-Host ('=' * 62)
-if (Test-Path $logPath) { Get-Content $logPath } else { Write-Host '(no log written)' -ForegroundColor Red }
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs
deleted file mode 100644
index eee486d..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/ClockSyncTests.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// The offset estimator, driven with synthetic timestamps so the answer is known exactly.
-///
-/// Worth testing precisely rather than approximately: every latency figure the system reports is
-/// this number subtracted from another, so an error here does not surface as a wrong measurement,
-/// it surfaces as a measurement that looks right.
-///
-public class ClockSyncTests
-{
- ///
- /// Builds one round trip for a peer whose clock reads
- /// microseconds later than ours, with the given one-way delay in each direction.
- ///
- private static (long T1, long T2, long T3, long T4) RoundTrip(
- long localSend, long remoteAhead, long delayOut, long remoteThinking, long delayBack)
- {
- long t1 = localSend;
- long t2 = localSend + delayOut + remoteAhead;
- long t3 = t2 + remoteThinking;
- long t4 = t3 - remoteAhead + delayBack;
- return (t1, t2, t3, t4);
- }
-
- [Fact]
- public void ASymmetricRoundTripRecoversTheOffsetExactly()
- {
- var sync = new ClockSync();
- (long t1, long t2, long t3, long t4) = RoundTrip(
- localSend: 1_000_000, remoteAhead: 250_000, delayOut: 300, remoteThinking: 50, delayBack: 300);
-
- sync.Record(t1, t2, t3, t4);
-
- // Signed as local minus remote, because that is how callers use it: a peer timestamp plus
- // the offset is that instant on our clock.
- Assert.Equal(-250_000, sync.OffsetMicroseconds);
- Assert.Equal(600, sync.RoundTripMicroseconds);
- Assert.True(sync.HasSample);
- }
-
- [Fact]
- public void TheResponderIsNotChargedForItsOwnThinkingTime()
- {
- var sync = new ClockSync();
-
- // A peer that sat on the ping for 5 ms before replying. That is not network delay, and
- // counting it would halve into the offset and skew every later measurement.
- (long t1, long t2, long t3, long t4) = RoundTrip(
- localSend: 0, remoteAhead: 0, delayOut: 200, remoteThinking: 5_000, delayBack: 200);
-
- sync.Record(t1, t2, t3, t4);
-
- Assert.Equal(0, sync.OffsetMicroseconds);
- Assert.Equal(400, sync.RoundTripMicroseconds);
- }
-
- [Fact]
- public void TheLowestRoundTripSampleWins()
- {
- var sync = new ClockSync();
-
- // A congested sample first. Its delays are lopsided, so its offset is wrong by half the
- // asymmetry — which is exactly the error the minimum-round-trip rule is there to reject.
- (long t1, long t2, long t3, long t4) = RoundTrip(
- localSend: 0, remoteAhead: 1_000, delayOut: 9_000, remoteThinking: 0, delayBack: 1_000);
- sync.Record(t1, t2, t3, t4);
- long congested = sync.OffsetMicroseconds;
-
- (t1, t2, t3, t4) = RoundTrip(
- localSend: 100_000, remoteAhead: 1_000, delayOut: 150, remoteThinking: 0, delayBack: 150);
- sync.Record(t1, t2, t3, t4);
-
- Assert.NotEqual(congested, sync.OffsetMicroseconds);
- Assert.Equal(-1_000, sync.OffsetMicroseconds);
- Assert.Equal(300, sync.RoundTripMicroseconds);
- Assert.Equal(2, sync.SampleCount);
- }
-
- [Fact]
- public void AWorseSampleDoesNotDisplaceABetterOne()
- {
- var sync = new ClockSync();
-
- (long t1, long t2, long t3, long t4) = RoundTrip(
- localSend: 0, remoteAhead: 500, delayOut: 100, remoteThinking: 0, delayBack: 100);
- sync.Record(t1, t2, t3, t4);
-
- (t1, t2, t3, t4) = RoundTrip(
- localSend: 10_000, remoteAhead: 500, delayOut: 20_000, remoteThinking: 0, delayBack: 100);
- sync.Record(t1, t2, t3, t4);
-
- Assert.Equal(-500, sync.OffsetMicroseconds);
- Assert.Equal(200, sync.RoundTripMicroseconds);
- }
-
- [Fact]
- public void ASampleFromAClockThatSteppedIsIgnored()
- {
- var sync = new ClockSync();
-
- // The reply appears to arrive before it was sent. Nothing usable can be extracted, and
- // folding it in would corrupt an estimate that may already be good.
- sync.Record(t1: 1_000, t2: 500, t3: 9_000, t4: 1_100);
-
- Assert.False(sync.HasSample);
- Assert.Equal(0, sync.OffsetMicroseconds);
- }
-
- [Fact]
- public void UntilAnExchangeCompletesTheOffsetIsZeroAndSaysSo()
- {
- var sync = new ClockSync();
-
- Assert.False(sync.HasSample);
- Assert.Equal(0, sync.OffsetMicroseconds);
- Assert.Equal(0, sync.RoundTripMicroseconds);
- }
-
- [Fact]
- public void APingRoundTripsThroughTheWireFormat()
- {
- Span buffer = stackalloc byte[ClockSync.PayloadSize];
- ClockSync.WritePing(buffer, 123_456);
-
- Assert.True(ClockSync.TryRead(buffer, out HeartbeatKind kind, out long t1, out long t2, out long t3));
- Assert.Equal(HeartbeatKind.Ping, kind);
- Assert.Equal(123_456, t1);
- Assert.Equal(0, t2);
- Assert.Equal(0, t3);
- }
-
- [Fact]
- public void APongCarriesTheOriginatorsInstantBackUntouched()
- {
- Span buffer = stackalloc byte[ClockSync.PayloadSize];
- ClockSync.WritePong(buffer, originatorSend: 111, localReceive: 222, localSend: 333);
-
- Assert.True(ClockSync.TryRead(buffer, out HeartbeatKind kind, out long t1, out long t2, out long t3));
- Assert.Equal(HeartbeatKind.Pong, kind);
-
- // The originator's instant must survive verbatim: it is on the originator's clock, and
- // reinterpreting it on the responder's would be the very error being corrected for.
- Assert.Equal(111, t1);
- Assert.Equal(222, t2);
- Assert.Equal(333, t3);
- }
-
- [Theory]
- [InlineData(0)]
- [InlineData(ClockSync.PayloadSize - 1)]
- [InlineData(ClockSync.PayloadSize + 1)]
- public void AWronglySizedHeartbeatIsRefused(int size)
- {
- Assert.False(ClockSync.TryRead(new byte[size], out _, out _, out _, out _));
- }
-
- [Fact]
- public void AnUnknownHeartbeatKindIsRefused()
- {
- byte[] payload = new byte[ClockSync.PayloadSize];
- payload[0] = 7;
-
- Assert.False(ClockSync.TryRead(payload, out _, out _, out _, out _));
- }
-}
-
-/// The wire clock itself.
-public class MonotonicClockTests
-{
- [Fact]
- public void ConversionSurvivesATimestampFromAMachineThatHasBeenUpForYears()
- {
- // The obvious form, ticks * 1_000_000 / Frequency, overflows a long after about eleven
- // days at 10 MHz. That is not an edge case for a service that starts at boot and is
- // expected never to be restarted, and the symptom is a negative latency, not a crash.
- long threeYearsOfTicks = System.Diagnostics.Stopwatch.Frequency * 60L * 60 * 24 * 365 * 3;
-
- long microseconds = MonotonicClock.ToMicroseconds(threeYearsOfTicks);
-
- Assert.True(microseconds > 0, $"conversion overflowed, giving {microseconds}");
-
- // Within a second over three years. Exact equality would be asserting that the machine's
- // tick frequency divides a million, which is true here and not guaranteed anywhere.
- long expectedSeconds = 60L * 60 * 24 * 365 * 3;
- Assert.InRange(microseconds / 1_000_000, expectedSeconds - 1, expectedSeconds + 1);
- }
-
- [Fact]
- public void TheClockMovesForwards()
- {
- long first = MonotonicClock.Microseconds;
- Thread.Sleep(2);
- long second = MonotonicClock.Microseconds;
-
- Assert.True(second > first);
- Assert.True(second - first < 1_000_000, "two milliseconds should not read as a second");
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs
deleted file mode 100644
index 8e346c7..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterMembershipTests.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class ClusterMembershipTests : IDisposable
-{
- private const string Thumbprint = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA";
-
- private readonly string _directory =
- Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N"));
-
- private ClusterStore Store() => new(Path.Combine(_directory, "roster.json"));
-
- private LocalSettingsStore Settings() => new(Path.Combine(_directory, "local.json"));
-
- private ClusterMembership Create() => new(Store(), Settings());
-
- private ClusterMembership CreateWithCluster()
- {
- ClusterMembership membership = Create();
- membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", Thumbprint, "Desk");
- return membership;
- }
-
- [Fact]
- public void AFreshMachineBelongsToNoCluster()
- {
- ClusterMembership membership = Create();
-
- Assert.False(membership.HasCluster);
- Assert.Empty(membership.Machines);
- Assert.Equal(LoadOutcome.Absent, membership.LoadOutcome);
- }
-
- [Fact]
- public void AMachineGetsOneIdentityAndKeepsIt()
- {
- // The node id is what every peer pins a certificate against, so a machine that generated
- // a new one on each start would look like a different computer after every reboot.
- Guid first = Create().LocalNodeId;
- Guid second = Create().LocalNodeId;
-
- Assert.NotEqual(Guid.Empty, first);
- Assert.Equal(first, second);
- }
-
- [Fact]
- public void CreatingAClusterYieldsAOneMemberRosterContainingThisMachine()
- {
- ClusterMembership membership = CreateWithCluster();
-
- RosterEntry only = Assert.Single(membership.Machines);
-
- Assert.Equal(membership.LocalNodeId, only.NodeId);
- Assert.Equal("Desk", only.DisplayName);
- Assert.Equal(Thumbprint, only.Thumbprint);
- Assert.True(membership.HasCluster);
- }
-
- [Fact]
- public void MembershipSurvivesARestart()
- {
- // The point of persisting it: whoever becomes host after a cold boot uses the last saved
- // membership rather than reconstructing it from whoever happens to be online.
- Guid nodeId = CreateWithCluster().LocalNodeId;
-
- ClusterMembership reloaded = Create();
-
- Assert.True(reloaded.HasCluster);
- Assert.Equal("Home", reloaded.ClusterName);
- Assert.Equal("AAAA-BBBB-CCCC-DDDD", reloaded.JoinKey);
- Assert.Equal(nodeId, Assert.Single(reloaded.Machines).NodeId);
- }
-
- [Fact]
- public void AMachineCannotJoinASecondClusterWithoutLeavingTheFirst()
- {
- // Belonging to two clusters would mean two sets of peers each believing they may inject
- // keystrokes here, with nothing reconciling them.
- ClusterMembership membership = CreateWithCluster();
-
- Assert.Throws(
- () => membership.CreateCluster("Other", "FEDCBA9876543210", "EEEE-FFFF-GGGG-HHHH", Thumbprint, "Desk"));
- }
-
- [Fact]
- public void LeavingClearsTheClusterButKeepsThisMachinesIdentity()
- {
- ClusterMembership membership = CreateWithCluster();
- Guid nodeId = membership.LocalNodeId;
-
- membership.LeaveCluster();
-
- Assert.False(membership.HasCluster);
- Assert.Empty(membership.Machines);
- Assert.Equal(string.Empty, membership.JoinKey);
- Assert.Equal(nodeId, Create().LocalNodeId);
- }
-
- [Fact]
- public void ThisMachineCannotRemoveItself()
- {
- // Removing yourself would leave a machine that is a member of a cluster it is tombstoned
- // in, which would keep trying to rejoin and keep being refused.
- ClusterMembership membership = CreateWithCluster();
-
- Assert.Throws(() => membership.RemoveMachine(membership.LocalNodeId));
- }
-
- [Fact]
- public void RemovingAMachineThatIsNotAMemberReportsSoRatherThanThrowing()
- {
- ClusterMembership membership = CreateWithCluster();
-
- Assert.False(membership.RemoveMachine(Guid.NewGuid()));
- }
-
- [Fact]
- public void RemovingAMachineLeavesATombstoneRatherThanAnAbsence()
- {
- // A peer that was powered off during the removal must not merge the machine — and its
- // certificate pin — back in when it returns.
- ClusterMembership membership = CreateWithCluster();
- Guid peer = EnrollPeer(membership, "Laptop");
-
- Assert.True(membership.RemoveMachine(peer));
-
- Assert.DoesNotContain(membership.Machines, entry => entry.NodeId == peer);
- Assert.Equal(MembershipState.Removed, membership.Find(peer)?.State);
- }
-
- [Fact]
- public void RemovingAMachineClearsAnyHotkeyPointingAtIt()
- {
- // Otherwise a familiar key would stay bound to a machine that is no longer a member, and
- // pressing it would silently do nothing while still looking configured.
- LocalSettingsStore settings = Settings();
- ClusterMembership membership = new(Store(), settings);
- membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", Thumbprint, "Desk");
-
- Guid peer = EnrollPeer(membership, "Laptop");
- settings.Save(settings.Bind(settings.Load(), 3, peer));
-
- membership.RemoveMachine(peer);
-
- Assert.Null(LocalSettingsStore.TargetFor(settings.Load(), 3));
- }
-
- [Fact]
- public void MergingAPeersViewBringsInMachinesItKnowsAbout()
- {
- ClusterMembership membership = CreateWithCluster();
-
- var peerId = Guid.NewGuid();
- var theirRoster = new Roster(peerId);
- theirRoster.Enroll(peerId, Thumbprint, "Laptop");
-
- Assert.True(membership.Merge(theirRoster.Snapshot()));
-
- Assert.Contains(membership.Machines, entry => entry.NodeId == peerId && entry.DisplayName == "Laptop");
- }
-
- [Fact]
- public void MergingTheSameViewTwiceChangesNothingTheSecondTime()
- {
- // Anti-entropy will replay snapshots constantly, and each replay must not count as a
- // change or the roster would be rewritten to disk forever.
- ClusterMembership membership = CreateWithCluster();
-
- var peerId = Guid.NewGuid();
- var theirRoster = new Roster(peerId);
- theirRoster.Enroll(peerId, Thumbprint, "Laptop");
- RosterSnapshot snapshot = theirRoster.Snapshot();
-
- Assert.True(membership.Merge(snapshot));
- Assert.False(membership.Merge(snapshot));
- }
-
- [Fact]
- public void ThisMachineIsListedFirst()
- {
- // It is the one the person is sitting at, and the one they cannot remove, so it is not
- // left to sort in wherever its name happens to fall.
- ClusterMembership membership = CreateWithCluster();
- EnrollPeer(membership, "Aardvark");
-
- Assert.True(membership.Machines[0].NodeId == membership.LocalNodeId);
- }
-
- /// Adds a peer the way a completed enrollment eventually will.
- private static Guid EnrollPeer(ClusterMembership membership, string name)
- {
- var peerId = Guid.NewGuid();
- var theirRoster = new Roster(peerId);
- theirRoster.Enroll(peerId, Thumbprint, name);
- membership.Merge(theirRoster.Snapshot());
- return peerId;
- }
-
- public void Dispose()
- {
- try
- {
- if (Directory.Exists(_directory))
- {
- Directory.Delete(_directory, recursive: true);
- }
- }
- catch
- {
- // Best effort.
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs
deleted file mode 100644
index 870bf09..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterRegistryTests.cs
+++ /dev/null
@@ -1,152 +0,0 @@
-using Microsoft.Extensions.Logging.Abstractions;
-using VirtualKmSwitch.Cluster;
-using VirtualKmSwitch.Ipc;
-using VirtualKmSwitch.Service;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class ClusterRegistryTests : IDisposable
-{
- private const string Thumbprint = "0011223344556677889900112233445566778899001122334455667788990011";
-
- private readonly string _directory =
- Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N"));
-
- private string RosterPath => Path.Combine(_directory, "roster.json");
-
- private ClusterRegistry Create() => new(
- NullLogger.Instance,
- new ClusterStore(RosterPath),
- new LocalSettingsStore(Path.Combine(_directory, "local.json")),
-
- // Stubbed so the test does not create a certificate in a Windows store and leave it on
- // whatever machine ran it.
- _ => Thumbprint);
-
- [Fact]
- public void AFreshMachineIsDescribedAsBelongingToNothing()
- {
- ClusterView view = Create().Describe();
-
- Assert.False(view.HasCluster);
- Assert.Empty(view.Machines);
- Assert.Equal(ClusterHealth.Ok, view.Health);
- Assert.NotEqual(Guid.Empty, view.LocalNodeId);
- }
-
- [Fact]
- public void CreatingAClusterPutsThisMachineInTheDescribedRoster()
- {
- ClusterRegistry registry = Create();
- registry.CreateCluster("Workshop");
-
- ClusterView view = registry.Describe();
-
- Assert.True(view.HasCluster);
- Assert.Equal("Workshop", view.ClusterName);
- Assert.NotEmpty(view.ClusterId);
-
- MachineInfo only = Assert.Single(view.Machines);
- Assert.True(only.IsLocal);
- Assert.Equal(view.LocalNodeId, only.NodeId);
- Assert.Equal(Environment.MachineName, only.DisplayName);
- Assert.Equal(Thumbprint, only.Thumbprint);
- }
-
- [Fact]
- public void TheJoinKeyIsWithheldUnlessItIsExplicitlyAskedFor()
- {
- // Every routine membership read goes to the controller, so a key attached by default
- // would sit in an unelevated process's memory for the whole session — for the sake of a
- // string that is only useful in the seconds after it is created.
- ClusterRegistry registry = Create();
- string created = registry.CreateCluster("Workshop");
-
- Assert.Null(registry.Describe().JoinKey);
- Assert.Equal(created, registry.Describe(includeJoinKey: true).JoinKey);
- }
-
- [Fact]
- public void TheClusterIdDoesNotRevealTheJoinKey()
- {
- // Beacons carry the id in the clear, so anything recoverable from it is public.
- ClusterRegistry registry = Create();
- string key = registry.CreateCluster("Workshop");
-
- ClusterView view = registry.Describe(includeJoinKey: true);
-
- Assert.DoesNotContain(JoinKeyWithoutDashes(key), view.ClusterId, StringComparison.OrdinalIgnoreCase);
- Assert.Equal(16, view.ClusterId.Length);
- }
-
- [Fact]
- public void AMachineThatIsNotAMemberCannotBeRemoved()
- {
- ClusterRegistry registry = Create();
- registry.CreateCluster("Workshop");
-
- Assert.False(registry.RemoveMachine(Guid.NewGuid()));
- }
-
- [Fact]
- public void LeavingIsDescribedAsBelongingToNothingAgain()
- {
- ClusterRegistry registry = Create();
- registry.CreateCluster("Workshop");
- registry.LeaveCluster();
-
- ClusterView view = registry.Describe(includeJoinKey: true);
-
- Assert.False(view.HasCluster);
- Assert.Empty(view.Machines);
- Assert.Null(view.JoinKey);
- }
-
- [Fact]
- public void ADamagedRosterIsReportedRatherThanPresentedAsAnEmptyCluster()
- {
- // An empty roster and an unreadable one look identical on screen, and only one of them
- // means every peer has genuinely left. This also covers the first roster ever written,
- // which is the copy a freshly joined machine depends on.
- Create().CreateCluster("Workshop");
- File.WriteAllText(RosterPath, "{ not json");
-
- ClusterView view = Create().Describe();
-
- Assert.Equal(ClusterHealth.RecoveredFromBackup, view.Health);
- Assert.NotNull(view.HealthDetail);
- }
-
- [Fact]
- public void AnUnreadableRosterIsReportedAsSuch()
- {
- ClusterStore store = new(RosterPath);
- Create().CreateCluster("Workshop");
-
- File.WriteAllText(RosterPath, "{ not json");
- File.WriteAllText(store.BackupPath, "{ also not json");
-
- ClusterView view = Create().Describe();
-
- Assert.Equal(ClusterHealth.Unreadable, view.Health);
- Assert.False(view.HasCluster);
- }
-
- private static string JoinKeyWithoutDashes(string key) => key.Replace("-", string.Empty);
-
- public void Dispose()
- {
- try
- {
- if (Directory.Exists(_directory))
- {
- Directory.Delete(_directory, recursive: true);
- }
- }
- catch
- {
- // Best effort.
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs
deleted file mode 100644
index b3a9e91..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/ClusterStoreTests.cs
+++ /dev/null
@@ -1,310 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class ClusterStoreTests : IDisposable
-{
- private readonly string _directory =
- Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N"));
-
- private string RosterPath => Path.Combine(_directory, "roster.json");
-
- private ClusterStore CreateStore() => new(RosterPath);
-
- private static PersistedRoster SampleRoster(string clusterName = "home") => new()
- {
- ClusterId = "ABCD1234",
- ClusterName = clusterName,
- Clock = 7,
- Entries =
- [
- new RosterEntry
- {
- NodeId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
- Thumbprint = "THUMB-A",
- DisplayName = "desktop",
- State = MembershipState.Enrolled,
- StateChangedAt = new LamportStamp(3, Guid.Empty),
- },
- new RosterEntry
- {
- NodeId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
- Thumbprint = "THUMB-B",
- DisplayName = "laptop",
- State = MembershipState.Removed,
- StateChangedAt = new LamportStamp(7, Guid.Empty),
- },
- ],
- };
-
- [Fact]
- public void AMissingFileIsReportedAsAbsentRatherThanEmpty()
- {
- // The distinction matters: an empty roster looks exactly like every peer having left.
- LoadResult result = CreateStore().Load();
-
- Assert.Equal(LoadOutcome.Absent, result.Outcome);
- Assert.Null(result.Roster);
- }
-
- [Fact]
- public void RostersRoundTrip()
- {
- ClusterStore store = CreateStore();
- store.Save(SampleRoster());
-
- LoadResult result = store.Load();
-
- Assert.Equal(LoadOutcome.Loaded, result.Outcome);
- Assert.Equal("ABCD1234", result.Roster!.ClusterId);
- Assert.Equal(7UL, result.Roster.Clock);
- Assert.Equal(2, result.Roster.Entries.Count);
-
- // Tombstones must persist, or a restart would resurrect removed machines.
- Assert.Contains(result.Roster.Entries, entry => entry.State == MembershipState.Removed);
- }
-
- [Fact]
- public void SavingKeepsThePreviousCopyAsABackup()
- {
- ClusterStore store = CreateStore();
- store.Save(SampleRoster("first"));
- store.Save(SampleRoster("second"));
-
- Assert.True(File.Exists(store.BackupPath));
- Assert.Equal("second", store.Load().Roster!.ClusterName);
- }
-
- [Fact]
- public void TheVeryFirstSaveAlsoLeavesABackup()
- {
- // Otherwise a machine that has just joined a cluster holds exactly one copy of the
- // membership it would need to rejoin, until something happens to write a second time.
- // Losing it there means re-enrolling from another machine, which is precisely the task a
- // KVM cannot help with.
- ClusterStore store = CreateStore();
- store.Save(SampleRoster("first"));
-
- Assert.True(File.Exists(store.BackupPath));
-
- File.WriteAllText(store.FilePath, "{ corrupt");
-
- LoadResult result = store.Load();
-
- Assert.Equal(LoadOutcome.RecoveredFromBackup, result.Outcome);
- Assert.Equal("first", result.Roster!.ClusterName);
- }
-
- [Fact]
- public void ACorruptPrimaryFallsBackToTheBackup()
- {
- // The power-cut case. Without the fallback, membership becomes unreadable — and this is
- // the tool you would otherwise use to reach the other machines and fix it.
- ClusterStore store = CreateStore();
- store.Save(SampleRoster("original"));
- store.Save(SampleRoster("current"));
-
- File.WriteAllText(RosterPath, "{ this is not json");
-
- LoadResult result = store.Load();
-
- Assert.Equal(LoadOutcome.RecoveredFromBackup, result.Outcome);
- Assert.Equal("original", result.Roster!.ClusterName);
- Assert.NotNull(result.Error);
- }
-
- [Fact]
- public void AnEmptyPrimaryIsTreatedAsCorrupt()
- {
- // A zero-length file is what an interrupted write leaves behind, and it parses as nothing
- // rather than failing loudly.
- ClusterStore store = CreateStore();
- store.Save(SampleRoster("original"));
- store.Save(SampleRoster("current"));
-
- File.WriteAllBytes(RosterPath, []);
-
- Assert.Equal(LoadOutcome.RecoveredFromBackup, store.Load().Outcome);
- }
-
- [Fact]
- public void BothCopiesUnreadableIsReportedRatherThanSilentlyEmpty()
- {
- ClusterStore store = CreateStore();
- store.Save(SampleRoster("first"));
- store.Save(SampleRoster("second"));
-
- File.WriteAllText(RosterPath, "corrupt");
- File.WriteAllText(store.BackupPath, "also corrupt");
-
- LoadResult result = store.Load();
-
- Assert.Equal(LoadOutcome.Unrecoverable, result.Outcome);
- Assert.Null(result.Roster);
- Assert.NotNull(result.Error);
- }
-
- [Fact]
- public void UnknownFieldsSurviveARoundTrip()
- {
- // A peer on an older build must not strip settings it does not understand: it would write
- // the loss back and quietly remove them from the whole mesh.
- ClusterStore store = CreateStore();
- store.Save(SampleRoster());
-
- string json = File.ReadAllText(RosterPath);
- json = json.TrimEnd().TrimEnd('}') + ", \"futureSetting\": { \"nested\": 42 } }";
- File.WriteAllText(RosterPath, json);
-
- LoadResult loaded = store.Load();
- Assert.Equal(LoadOutcome.Loaded, loaded.Outcome);
- Assert.NotNull(loaded.Roster!.Extra);
- Assert.True(loaded.Roster.Extra!.ContainsKey("futureSetting"));
-
- store.Save(loaded.Roster);
- Assert.Contains("futureSetting", File.ReadAllText(RosterPath));
- }
-
- [Fact]
- public void NoTemporaryFileIsLeftBehind()
- {
- ClusterStore store = CreateStore();
- store.Save(SampleRoster());
- store.Save(SampleRoster());
-
- Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
- }
-
- [Fact]
- public void ARosterSurvivesTheFullRoundTripThroughTheCrdt()
- {
- var nodeA = Guid.NewGuid();
- var roster = new Roster(nodeA);
- roster.Enroll(nodeA, "THUMB-SELF", "self");
- roster.Enroll(Guid.NewGuid(), "THUMB-OTHER", "other");
- roster.Remove(roster.EnrolledEntries.Last().NodeId);
-
- ClusterStore store = CreateStore();
- RosterSnapshot snapshot = roster.Snapshot();
-
- store.Save(new PersistedRoster
- {
- ClusterId = "X",
- Clock = snapshot.Clock,
- Entries = snapshot.Entries,
- });
-
- PersistedRoster reloaded = store.Load().Roster!;
- var restored = new Roster(nodeA);
- restored.Load(new RosterSnapshot { Clock = reloaded.Clock, Entries = reloaded.Entries });
-
- Assert.Equal(roster.EnrolledCount, restored.EnrolledCount);
- Assert.Equal(roster.Clock, restored.Clock);
- Assert.Equal(roster.AllEntries.Count, restored.AllEntries.Count);
- }
-
- public void Dispose()
- {
- try
- {
- if (Directory.Exists(_directory))
- {
- Directory.Delete(_directory, recursive: true);
- }
- }
- catch
- {
- // Temp cleanup is best effort.
- }
- }
-}
-
-public class ControlLeasesTests
-{
- private static readonly Guid MachineA = Guid.NewGuid();
- private static readonly Guid PeerOne = Guid.NewGuid();
- private static readonly Guid PeerTwo = Guid.NewGuid();
-
- private static TimeSpan At(double seconds) => TimeSpan.FromSeconds(seconds);
-
- [Fact]
- public void TheFirstRequestWinsAndTheSecondIsRefused()
- {
- // Two people aiming at one machine would otherwise interleave keystrokes into a single
- // stream, unusable for both and mystifying to each.
- var leases = new ControlLeases();
-
- Assert.Equal(LeaseResult.Granted, leases.Request(At(0), MachineA, PeerOne));
- Assert.Equal(LeaseResult.HeldByAnother, leases.Request(At(0), MachineA, PeerTwo));
- Assert.Equal(PeerOne, leases.HolderOf(At(0), MachineA));
- }
-
- [Fact]
- public void TheHolderRenewsRatherThanBeingRefused()
- {
- var leases = new ControlLeases();
- leases.Request(At(0), MachineA, PeerOne);
-
- Assert.Equal(LeaseResult.Renewed, leases.Request(At(1), MachineA, PeerOne));
- Assert.Equal(PeerOne, leases.HolderOf(At(3.5), MachineA));
- }
-
- [Fact]
- public void ALeaseExpiresSoADeadPeerDoesNotHoldAMachineForever()
- {
- // The usual ending is a machine sleeping or losing the network, not a polite release.
- var leases = new ControlLeases();
- leases.Request(At(0), MachineA, PeerOne);
-
- Assert.Null(leases.HolderOf(At(10), MachineA));
- Assert.Equal(LeaseResult.Granted, leases.Request(At(10), MachineA, PeerTwo));
- }
-
- [Fact]
- public void ReleasingFreesTheTargetImmediately()
- {
- var leases = new ControlLeases();
- leases.Request(At(0), MachineA, PeerOne);
-
- Assert.True(leases.Release(MachineA, PeerOne));
- Assert.Equal(LeaseResult.Granted, leases.Request(At(0), MachineA, PeerTwo));
- }
-
- [Fact]
- public void OnlyTheHolderCanRelease()
- {
- var leases = new ControlLeases();
- leases.Request(At(0), MachineA, PeerOne);
-
- Assert.False(leases.Release(MachineA, PeerTwo));
- Assert.Equal(PeerOne, leases.HolderOf(At(0), MachineA));
- }
-
- [Fact]
- public void DisconnectingReleasesEverythingThatPeerHeld()
- {
- var leases = new ControlLeases();
- var machineB = Guid.NewGuid();
-
- leases.Request(At(0), MachineA, PeerOne);
- leases.Request(At(0), machineB, PeerOne);
-
- Assert.Equal(2, leases.ReleaseAllHeldBy(PeerOne));
- Assert.Null(leases.HolderOf(At(0), MachineA));
- Assert.Null(leases.HolderOf(At(0), machineB));
- }
-
- [Fact]
- public void ANewHostRebuildsLeasesFromWhatPeersReport()
- {
- // Leases describe who is typing right now, so carrying them across a host change would
- // hand out stale claims. They are rebuilt, never persisted.
- var leases = new ControlLeases();
- leases.Rebuild([new ControlLease(MachineA, PeerTwo, At(5))]);
-
- Assert.Equal(PeerTwo, leases.HolderOf(At(1), MachineA));
- Assert.Single(leases.Active(At(1)));
- Assert.Empty(leases.Active(At(6)));
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs
deleted file mode 100644
index 8d0d9e1..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/ControlPipeTests.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-using VirtualKmSwitch.Ipc;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class ControlProtocolTests
-{
- [Theory]
- [InlineData(ControlVerb.GetStatus)]
- [InlineData(ControlVerb.Engage)]
- [InlineData(ControlVerb.Disengage)]
- [InlineData(ControlVerb.SetHotkeys)]
- [InlineData(ControlVerb.ClaimHost)]
- public void RuntimeVerbsAreAvailableToTheInteractiveUser(ControlVerb verb) =>
- Assert.Equal(ControlTier.Runtime, ControlProtocol.TierFor(verb));
-
- [Theory]
- [InlineData(ControlVerb.CreateCluster)]
- [InlineData(ControlVerb.JoinCluster)]
- [InlineData(ControlVerb.LeaveCluster)]
- [InlineData(ControlVerb.EnrollPeer)]
- [InlineData(ControlVerb.RemovePeer)]
- [InlineData(ControlVerb.OpenPairingWindow)]
- public void MembershipChangesRequireAdministrator(ControlVerb verb) =>
- Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor(verb));
-
- [Fact]
- public void UnclassifiedVerbsDefaultToAdministrative()
- {
- // A verb added later without being classified must fail closed. The alternative — silently
- // exposing a new operation to any local user — is the dangerous direction.
- Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor(ControlVerb.Unknown));
- Assert.Equal(ControlTier.Administrative, ControlProtocol.TierFor((ControlVerb)9999));
- }
-}
-
-///
-/// End-to-end over a real named pipe: a real server, a real client, real ACLs. Only the caller's
-/// privilege is simulated, since a test process cannot drop to a different token on demand.
-///
-public class ControlPipeServerTests
-{
- ///
- /// A unique pipe per test. Using the production name would attach to a VirtualKmSwitch service if
- /// one happens to be installed on the machine, and the tests would then silently exercise that
- /// instead — passing or failing for reasons entirely unrelated to the code under test.
- ///
- private static string UniquePipeName([System.Runtime.CompilerServices.CallerMemberName] string caller = "") =>
- $"VirtualKmSwitch.Test.{caller}.{Environment.ProcessId}";
-
- private static async Task ConnectAsync(string pipeName)
- {
- var client = new ControlPipeClient(pipeName);
- await client.ConnectAsync(TimeSpan.FromSeconds(5));
- return client;
- }
-
- [Fact]
- public async Task RequestsRoundTripOverTheRealPipe()
- {
- string pipeName = UniquePipeName();
- await using var server = new ControlPipeServer(
- (request, _) => ValueTask.FromResult(ControlResponse.Ok(new Dictionary
- {
- ["echo"] = request.Verb.ToString(),
- })),
- pipeName: pipeName);
-
- await using ControlPipeClient client = await ConnectAsync(pipeName);
-
- ControlResponse response = await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus });
-
- Assert.True(response.Success);
- Assert.Equal("GetStatus", response.Values?["echo"]);
- }
-
- [Fact]
- public async Task ArgumentsSurviveTheRoundTrip()
- {
- Dictionary? seen = null;
-
- string pipeName = UniquePipeName();
- await using var server = new ControlPipeServer(
- (request, _) =>
- {
- seen = request.Arguments;
- return ValueTask.FromResult(ControlResponse.Ok());
- },
- pipeName: pipeName);
-
- await using ControlPipeClient client = await ConnectAsync(pipeName);
-
- await client.SendAsync(new ControlRequest
- {
- Verb = ControlVerb.Engage,
- Arguments = new Dictionary { ["slot"] = "3" },
- });
-
- Assert.Equal("3", seen?["slot"]);
- }
-
- [Fact]
- public async Task SeveralRequestsShareOneConnection()
- {
- string pipeName = UniquePipeName();
- await using var server = new ControlPipeServer(
- (request, _) => ValueTask.FromResult(ControlResponse.Ok(new Dictionary
- {
- ["verb"] = request.Verb.ToString(),
- })),
- pipeName: pipeName);
-
- await using ControlPipeClient client = await ConnectAsync(pipeName);
-
- // Responses are matched to requests by order alone, so this is what proves the framing
- // stays aligned across messages.
- foreach (ControlVerb verb in new[] { ControlVerb.GetStatus, ControlVerb.Engage, ControlVerb.Disengage })
- {
- ControlResponse response = await client.SendAsync(new ControlRequest { Verb = verb });
- Assert.Equal(verb.ToString(), response.Values?["verb"]);
- }
- }
-
- [Fact]
- public async Task AHandlerThatThrowsBecomesAFailureNotADroppedConnection()
- {
- string pipeName = UniquePipeName();
- await using var server = new ControlPipeServer(
- (_, _) => throw new InvalidOperationException("handler exploded"),
- pipeName: pipeName);
-
- await using ControlPipeClient client = await ConnectAsync(pipeName);
-
- ControlResponse response = await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus });
-
- Assert.False(response.Success);
- Assert.Contains("handler exploded", response.Error);
- }
-
- [Fact]
- public async Task TheHandlerIsToldWhichTierTheCallerHas()
- {
- ControlTier? observed = null;
-
- string pipeName = UniquePipeName();
- await using var server = new ControlPipeServer(
- (_, tier) =>
- {
- observed = tier;
- return ValueTask.FromResult(ControlResponse.Ok());
- },
- pipeName: pipeName);
-
- await using ControlPipeClient client = await ConnectAsync(pipeName);
- await client.SendAsync(new ControlRequest { Verb = ControlVerb.GetStatus });
-
- // The value depends on whether the test process is elevated; what matters is that the
- // server determined it from the connection rather than from anything the client sent.
- Assert.NotNull(observed);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs
deleted file mode 100644
index 3508e44..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/EnrollmentTests.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using System.Security.Cryptography.X509Certificates;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class EnrollmentHandshakeTests
-{
- private const string JoinKey = "TEST-KEY-0000-0001";
- private const string AliceThumb = "AAAA1111";
- private const string BobThumb = "BBBB2222";
-
- private static (byte[] Initiator, byte[] Responder) Nonces() =>
- (EnrollmentHandshake.CreateNonce(), EnrollmentHandshake.CreateNonce());
-
- [Fact]
- public void BothSidesComputeTheSameProofForTheSameExchange()
- {
- (byte[] initiator, byte[] responder) = Nonces();
-
- // Each side names its own certificate first and the peer's second, so the two computations
- // are mirror images that must agree.
- byte[] fromAlice = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
- byte[] fromBobsView = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
-
- Assert.Equal(fromAlice, fromBobsView);
-
- Assert.Equal(
- EnrollmentResult.Accepted,
- EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, initiator, responder, fromAlice));
- }
-
- [Fact]
- public void TheWrongKeyIsRefused()
- {
- (byte[] initiator, byte[] responder) = Nonces();
- byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
-
- Assert.Equal(
- EnrollmentResult.WrongKey,
- EnrollmentHandshake.Verify("DIFFERENT-KEY", AliceThumb, BobThumb, initiator, responder, proof));
- }
-
- [Fact]
- public void AProofForDifferentCertificatesIsRefused()
- {
- // The man-in-the-middle case. An interceptor can relay a genuine proof between two honest
- // peers, but it must present its own certificate to each of them -- so the thumbprints it
- // is observed with differ from the ones the proof was computed over, and it fails.
- (byte[] initiator, byte[] responder) = Nonces();
- byte[] genuine = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
-
- const string AttackerThumb = "CCCC3333";
-
- Assert.Equal(
- EnrollmentResult.WrongKey,
- EnrollmentHandshake.Verify(JoinKey, AliceThumb, AttackerThumb, initiator, responder, genuine));
-
- Assert.Equal(
- EnrollmentResult.WrongKey,
- EnrollmentHandshake.Verify(JoinKey, AttackerThumb, BobThumb, initiator, responder, genuine));
- }
-
- [Fact]
- public void SwappingTheCertificateOrderChangesTheProof()
- {
- // Direction must matter, or a proof computed by one side would validate as the other's.
- (byte[] initiator, byte[] responder) = Nonces();
-
- byte[] forward = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
- byte[] reversed = EnrollmentHandshake.ComputeProof(JoinKey, BobThumb, AliceThumb, initiator, responder);
-
- Assert.NotEqual(forward, reversed);
- }
-
- [Fact]
- public void ReplayingAProofWithFreshNoncesFails()
- {
- // Nonces are what stop a captured exchange being replayed later against the same pair.
- (byte[] initiator, byte[] responder) = Nonces();
- byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, AliceThumb, BobThumb, initiator, responder);
-
- (byte[] freshInitiator, byte[] freshResponder) = Nonces();
-
- Assert.Equal(
- EnrollmentResult.WrongKey,
- EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, freshInitiator, freshResponder, proof));
- }
-
- [Fact]
- public void ThumbprintCasingDoesNotChangeTheOutcome()
- {
- // Thumbprints come from different code paths on each side, and a casing difference must
- // not read as a different certificate.
- (byte[] initiator, byte[] responder) = Nonces();
- byte[] proof = EnrollmentHandshake.ComputeProof(JoinKey, "abcd1234", "ef567890", initiator, responder);
-
- Assert.Equal(
- EnrollmentResult.Accepted,
- EnrollmentHandshake.Verify(JoinKey, "ABCD1234", "EF567890", initiator, responder, proof));
- }
-
- [Fact]
- public void MalformedInputIsRejectedWithoutComparing()
- {
- (byte[] initiator, byte[] responder) = Nonces();
-
- Assert.Equal(
- EnrollmentResult.Malformed,
- EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, initiator, responder, new byte[8]));
-
- Assert.Equal(
- EnrollmentResult.Malformed,
- EnrollmentHandshake.Verify(JoinKey, AliceThumb, BobThumb, new byte[4], responder, new byte[32]));
- }
-
- [Fact]
- public void ClusterIdIsStableAndRevealsNothingObvious()
- {
- string id = EnrollmentHandshake.ClusterIdFor(JoinKey);
-
- Assert.Equal(id, EnrollmentHandshake.ClusterIdFor(JoinKey));
- Assert.NotEqual(id, EnrollmentHandshake.ClusterIdFor(JoinKey + "x"));
-
- // Broadcast on the LAN, so it must not contain the key it derives from.
- Assert.DoesNotContain(JoinKey, id, StringComparison.OrdinalIgnoreCase);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs
deleted file mode 100644
index 3ea1503..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/FrameCodecTests.cs
+++ /dev/null
@@ -1,206 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class FrameCodecTests
-{
- private static readonly InputEvent[] Sample =
- [
- InputEvent.MouseMove(1000, 12, -34),
- InputEvent.MouseButton(1000, 1, up: false),
- InputEvent.Key(1000, 0x1E, InputEventFlags.Extended),
- InputEvent.MouseWheel(1000, -120, horizontal: true),
- InputEvent.ReleaseAll(1000),
- ];
-
- [Fact]
- public void InputBatchesRoundTrip()
- {
- Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- Assert.True(FrameCodec.TryWriteInputBatch(buffer, 987654321, Sample, out int written));
- Assert.Equal(buffer.Length, written);
-
- Assert.Equal(
- FrameReadResult.Complete,
- FrameCodec.TryReadFrame(buffer, out FrameType type, out ReadOnlySpan payload, out int consumed));
-
- Assert.Equal(FrameType.InputBatch, type);
- Assert.Equal(written, consumed);
-
- var decoded = new InputEvent[Sample.Length];
- int count = FrameCodec.ReadInputBatch(payload, decoded, out long timestamp);
-
- Assert.Equal(Sample.Length, count);
- Assert.Equal(987654321, timestamp);
-
- for (int i = 0; i < Sample.Length; i++)
- {
- Assert.Equal(Sample[i].Kind, decoded[i].Kind);
- Assert.Equal(Sample[i].Flags, decoded[i].Flags);
- Assert.Equal(Sample[i].Code, decoded[i].Code);
- Assert.Equal(Sample[i].X, decoded[i].X);
- Assert.Equal(Sample[i].Y, decoded[i].Y);
- }
- }
-
- [Fact]
- public void ExtremeValuesSurviveEncoding()
- {
- // Deltas are signed and a wheel delta is negative; a width or signedness mistake here
- // would send the pointer the wrong way rather than fail visibly.
- InputEvent[] extremes =
- [
- new(long.MaxValue, InputEventKind.MouseMove, InputEventFlags.None, ushort.MaxValue, int.MinValue, int.MaxValue),
- new(long.MinValue, InputEventKind.Key, InputEventFlags.Up | InputEventFlags.E1, 0, -1, 1),
- ];
-
- Span buffer = new byte[FrameCodec.InputBatchSize(extremes.Length)];
- FrameCodec.TryWriteInputBatch(buffer, -5, extremes, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _);
-
- var decoded = new InputEvent[extremes.Length];
- FrameCodec.ReadInputBatch(payload, decoded, out long timestamp);
-
- Assert.Equal(-5, timestamp);
- Assert.Equal(int.MinValue, decoded[0].X);
- Assert.Equal(int.MaxValue, decoded[0].Y);
- Assert.Equal(ushort.MaxValue, decoded[0].Code);
- Assert.Equal(-1, decoded[1].X);
- }
-
- [Fact]
- public void PartialFramesReportIncompleteRatherThanFailing()
- {
- // TCP delivers a stream, not messages: a frame arriving in pieces is the normal case, not
- // an error, and treating it as one would drop healthy connections constantly.
- Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out int written);
-
- for (int prefix = 0; prefix < written; prefix++)
- {
- Assert.Equal(
- FrameReadResult.Incomplete,
- FrameCodec.TryReadFrame(buffer[..prefix], out _, out _, out _));
- }
-
- Assert.Equal(FrameReadResult.Complete, FrameCodec.TryReadFrame(buffer, out _, out _, out _));
- }
-
- [Fact]
- public void OversizedLengthIsRejected()
- {
- // The length prefix is attacker-controlled until checked. Trusting it would let a peer
- // dictate an allocation, or read past the buffer.
- Span buffer = new byte[16];
- System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(buffer, ushort.MaxValue);
- buffer[2] = (byte)FrameType.InputBatch;
-
- Assert.Equal(FrameReadResult.Malformed, FrameCodec.TryReadFrame(buffer, out _, out _, out _));
- }
-
- [Theory]
- [InlineData(0)] // Unknown
- [InlineData(99)] // never defined
- [InlineData(255)]
- public void UnknownFrameTypesAreRejected(byte type)
- {
- Span buffer = new byte[8];
- System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(buffer, 1);
- buffer[2] = type;
-
- Assert.Equal(FrameReadResult.Malformed, FrameCodec.TryReadFrame(buffer, out _, out _, out _));
- }
-
- [Fact]
- public void ADeclaredCountThatDisagreesWithThePayloadIsRejected()
- {
- // Believing the count over the actual bytes would read past the payload, which is the
- // classic way a length-prefixed parser becomes an information leak.
- Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _);
-
- byte[] tampered = payload.ToArray();
- System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(tampered.AsSpan(8), 9999);
-
- Assert.Equal(-1, FrameCodec.ReadInputBatch(tampered, new InputEvent[64], out _));
- }
-
- [Fact]
- public void TruncatedBatchPayloadIsRejected()
- {
- Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _);
-
- Assert.Equal(-1, FrameCodec.ReadInputBatch(payload[..^3], new InputEvent[64], out _));
- Assert.Equal(-1, FrameCodec.ReadInputBatch([], new InputEvent[64], out _));
- }
-
- [Fact]
- public void ADestinationTooSmallIsRejectedRatherThanOverflowing()
- {
- Span buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _);
-
- Assert.Equal(-1, FrameCodec.ReadInputBatch(payload, new InputEvent[2], out _));
- }
-
- [Fact]
- public void ConsecutiveFramesAreReadIndependently()
- {
- // Several frames arrive in one TCP read constantly; the consumed count is what keeps the
- // reader aligned across them.
- Span buffer = new byte[512];
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample.AsSpan(0, 2), out int first);
- FrameCodec.TryWriteInputBatch(buffer[first..], 2, Sample.AsSpan(2, 3), out int second);
-
- FrameCodec.TryReadFrame(buffer[..(first + second)], out _, out ReadOnlySpan firstPayload, out int consumed);
- Assert.Equal(first, consumed);
- Assert.Equal(2, FrameCodec.ReadInputBatch(firstPayload, new InputEvent[8], out long firstTimestamp));
- Assert.Equal(1, firstTimestamp);
-
- FrameCodec.TryReadFrame(buffer[consumed..(first + second)], out _, out ReadOnlySpan secondPayload, out _);
- Assert.Equal(3, FrameCodec.ReadInputBatch(secondPayload, new InputEvent[8], out long secondTimestamp));
- Assert.Equal(2, secondTimestamp);
- }
-
- [Fact]
- public void EncodingAndDecodingAllocateNothing()
- {
- // The wire path carries every event; allocation here would eventually mean gen0
- // collections, and a GC pause reaching the hook thread risks a silent unhook.
- byte[] buffer = new byte[FrameCodec.InputBatchSize(Sample.Length)];
- var decoded = new InputEvent[Sample.Length];
-
- FrameCodec.TryWriteInputBatch(buffer, 1, Sample, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan warm, out _);
- FrameCodec.ReadInputBatch(warm, decoded, out _);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
-
- for (int i = 0; i < 10_000; i++)
- {
- FrameCodec.TryWriteInputBatch(buffer, i, Sample, out _);
- FrameCodec.TryReadFrame(buffer, out _, out ReadOnlySpan payload, out _);
- FrameCodec.ReadInputBatch(payload, decoded, out _);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-
- [Fact]
- public void AMouseMoveCostsTwelveBytesInABatch()
- {
- // Guards the wire size against drifting upward unnoticed: at kilohertz rates a few extra
- // bytes per event is real bandwidth.
- int one = FrameCodec.InputBatchSize(1);
- int two = FrameCodec.InputBatchSize(2);
-
- Assert.Equal(FrameCodec.EncodedEventSize, two - one);
- Assert.Equal(12, two - one);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs
deleted file mode 100644
index f10a5a3..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/HeldKeyTrackerTests.cs
+++ /dev/null
@@ -1,172 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.InputEngine;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class HeldKeyTrackerTests
-{
- private const ushort ScanLeftShift = 0x2A;
- private const ushort ScanA = 0x1E;
- private const ushort ScanRightCtrl = 0x1D;
-
- private static InputEvent KeyDown(ushort scanCode, InputEventFlags flags = InputEventFlags.None) =>
- InputEvent.Key(1, scanCode, flags);
-
- private static InputEvent KeyUp(ushort scanCode, InputEventFlags flags = InputEventFlags.None) =>
- InputEvent.Key(1, scanCode, flags | InputEventFlags.Up);
-
- [Fact]
- public void HeldKeysAreReleasedAndTheSetIsCleared()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(KeyDown(ScanLeftShift));
- tracker.Record(KeyDown(ScanA));
-
- Assert.Equal(2, tracker.HeldCount);
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- int count = tracker.BuildReleaseAll(99, releases);
-
- Assert.Equal(2, count);
- Assert.All(releases[..count].ToArray(), e => Assert.True(e.IsUp));
- Assert.All(releases[..count].ToArray(), e => Assert.Equal(99, e.Timestamp));
- Assert.Equal(0, tracker.HeldCount);
-
- // A second call has nothing left to release.
- Assert.Equal(0, tracker.BuildReleaseAll(100, releases));
- }
-
- [Fact]
- public void ReleasedKeysAreNoLongerHeld()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(KeyDown(ScanA));
- tracker.Record(KeyUp(ScanA));
-
- Assert.Equal(0, tracker.HeldCount);
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- Assert.Equal(0, tracker.BuildReleaseAll(1, releases));
- }
-
- [Fact]
- public void ExtendedKeysAreTrackedSeparatelyFromTheirBaseScancode()
- {
- // Right Ctrl and left Ctrl share scancode 0x1D and differ only by the extended flag.
- // Collapsing them would release the wrong key, or miss one entirely.
- var tracker = new HeldKeyTracker();
- tracker.Record(KeyDown(ScanRightCtrl, InputEventFlags.Extended));
- tracker.Record(KeyDown(ScanRightCtrl));
-
- Assert.Equal(2, tracker.HeldCount);
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- int count = tracker.BuildReleaseAll(1, releases);
-
- Assert.Equal(2, count);
- Assert.Contains(releases[..count].ToArray(), e => (e.Flags & InputEventFlags.Extended) != 0);
- Assert.Contains(releases[..count].ToArray(), e => (e.Flags & InputEventFlags.Extended) == 0);
- }
-
- [Fact]
- public void VirtualKeysAreReleasedAsVirtualKeys()
- {
- // Pause travels as a virtual key because its scancode sequence cannot be replayed, so its
- // release has to go back the same way.
- var tracker = new HeldKeyTracker();
- tracker.Record(new InputEvent(1, InputEventKind.Key, InputEventFlags.VirtualKey, 0x13, 0, 0));
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- int count = tracker.BuildReleaseAll(1, releases);
-
- Assert.Equal(1, count);
- Assert.True((releases[0].Flags & InputEventFlags.VirtualKey) != 0);
- Assert.True(releases[0].IsUp);
- Assert.Equal(0x13, releases[0].Code);
- }
-
- [Fact]
- public void MouseButtonsAreReleasedToo()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(InputEvent.MouseButton(1, 0, up: false));
- tracker.Record(InputEvent.MouseButton(1, 2, up: false));
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- int count = tracker.BuildReleaseAll(1, releases);
-
- Assert.Equal(2, count);
- Assert.All(releases[..count].ToArray(), e => Assert.Equal(InputEventKind.MouseButton, e.Kind));
- Assert.All(releases[..count].ToArray(), e => Assert.True(e.IsUp));
- }
-
- [Fact]
- public void MotionAndWheelCarryNoStateToRelease()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(InputEvent.MouseMove(1, 10, 10));
- tracker.Record(InputEvent.MouseWheel(1, 120, horizontal: false));
-
- Assert.Equal(0, tracker.HeldCount);
- }
-
- [Fact]
- public void RepeatedKeyDownCountsOnce()
- {
- // Auto-repeat sends key-down many times without an intervening key-up.
- var tracker = new HeldKeyTracker();
- for (int i = 0; i < 50; i++)
- {
- tracker.Record(KeyDown(ScanA));
- }
-
- Assert.Equal(1, tracker.HeldCount);
- }
-
- [Fact]
- public void TrackersAreIndependentPerSourcePeer()
- {
- // Two peers may drive one machine at once; releasing one peer's Shift must not clear the
- // other's, or the second peer's typing silently loses its modifier.
- var fromPeerA = new HeldKeyTracker();
- var fromPeerB = new HeldKeyTracker();
-
- fromPeerA.Record(KeyDown(ScanLeftShift));
- fromPeerB.Record(KeyDown(ScanLeftShift));
-
- Span releases = new InputEvent[fromPeerA.MaxReleaseEvents];
- fromPeerA.BuildReleaseAll(1, releases);
-
- Assert.Equal(0, fromPeerA.HeldCount);
- Assert.Equal(1, fromPeerB.HeldCount);
- }
-
- [Fact]
- public void ClearForgetsWithoutEmittingReleases()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(KeyDown(ScanA));
- tracker.Clear();
-
- Span releases = new InputEvent[tracker.MaxReleaseEvents];
- Assert.Equal(0, tracker.BuildReleaseAll(1, releases));
- }
-
- [Fact]
- public void RecordingAllocatesNothing()
- {
- var tracker = new HeldKeyTracker();
- tracker.Record(KeyDown(ScanA));
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- tracker.Record(KeyDown(ScanA));
- tracker.Record(KeyUp(ScanA));
- tracker.Record(InputEvent.MouseMove(i, 1, 1));
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs
deleted file mode 100644
index 020efaa..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/HostElectionTests.cs
+++ /dev/null
@@ -1,379 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// The election matrix, driven by an injected clock so every timing case runs instantly and
-/// deterministically rather than by sleeping.
-///
-public class HostElectionTests
-{
- private static readonly Guid LowId = new("11111111-1111-1111-1111-111111111111");
- private static readonly Guid MidId = new("55555555-5555-5555-5555-555555555555");
- private static readonly Guid HighId = new("99999999-9999-9999-9999-999999999999");
-
- private static readonly ElectionTimings Timings = new();
-
- private static HostElection Node(Guid nodeId, int priority = 0, ulong term = 0) =>
- new(new NodeRank(priority, nodeId), term, Timings);
-
- private static TimeSpan At(double milliseconds) => TimeSpan.FromMilliseconds(milliseconds);
-
- /// Runs the clock forward in small steps, collecting what the node broadcasts.
- private static List Advance(HostElection election, double fromMs, double toMs, double stepMs = 50)
- {
- var effects = new List();
-
- for (double now = fromMs; now <= toMs; now += stepMs)
- {
- ElectionEffect effect = election.Tick(At(now));
- if (effect != ElectionEffect.None)
- {
- effects.Add(effect);
- }
- }
-
- return effects;
- }
-
- [Fact]
- public void ALoneNodeBecomesHostAfterTheDiscoveryWindow()
- {
- // A cluster of one -- the create-cluster case -- must reach a host without waiting on
- // anyone else to answer.
- HostElection node = Node(LowId);
-
- Assert.Equal(HostRole.Follower, node.Role);
-
- Advance(node, 0, 500);
- Assert.Equal(HostRole.Follower, node.Role);
-
- Advance(node, 500, 2500);
- Assert.Equal(HostRole.Host, node.Role);
- Assert.Equal(LowId, node.CurrentHost);
- Assert.True(node.Term > 0);
- }
-
- ///
- /// Advances the clock while a live host keeps heartbeating, which is what actually suppresses
- /// an election. A single heartbeat does not: after the timeout the host is presumed gone, and
- /// correctly so.
- ///
- private static void AdvanceWithLiveHost(
- HostElection election, double fromMs, double toMs, Guid hostId, HostClaim claim, double stepMs = 50)
- {
- double lastHeartbeat = fromMs;
-
- for (double now = fromMs; now <= toMs; now += stepMs)
- {
- if (now - lastHeartbeat >= Timings.HeartbeatInterval.TotalMilliseconds)
- {
- election.OnHeartbeat(At(now), hostId, claim);
- lastHeartbeat = now;
- }
-
- election.Tick(At(now));
- }
- }
-
- [Fact]
- public void ALiveHostSuppressesElectionsIndefinitely()
- {
- HostElection node = Node(LowId);
- var claim = new HostClaim(5, new NodeRank(0, HighId));
-
- node.OnHeartbeat(At(100), HighId, claim);
- AdvanceWithLiveHost(node, 100, 5000, HighId, claim);
-
- Assert.Equal(HostRole.Follower, node.Role);
- Assert.Equal(HighId, node.CurrentHost);
- Assert.Equal(5UL, node.Term);
- }
-
- [Fact]
- public void ASingleHeartbeatOnlyDelaysAnElectionByTheTimeout()
- {
- // The converse, and the reason the test above must keep heartbeating: one heartbeat proves
- // a host was alive at that instant, not that it still is.
- HostElection node = Node(LowId);
- node.OnHeartbeat(At(100), HighId, new HostClaim(5, new NodeRank(0, HighId)));
-
- Advance(node, 100, 700);
- Assert.Equal(HostRole.Follower, node.Role);
-
- Advance(node, 700, 2000);
- Assert.Equal(HostRole.Host, node.Role);
- }
-
- [Fact]
- public void AHostThatGoesSilentIsReplaced()
- {
- HostElection node = Node(LowId);
- node.OnHeartbeat(At(100), HighId, new HostClaim(3, new NodeRank(0, HighId)));
-
- // Still within the timeout: the host is presumed alive.
- Advance(node, 100, 700);
- Assert.Equal(HostRole.Follower, node.Role);
-
- Advance(node, 700, 2000);
- Assert.Equal(HostRole.Host, node.Role);
- Assert.True(node.Term > 3);
- }
-
- [Fact]
- public void ABetterRankedNodeJoiningDoesNotDisturbAHealthyHost()
- {
- // The rule that keeps the role stable as a cluster wakes up. Without it, every machine
- // powering on in the morning would trigger a handover.
- HostElection host = Node(HighId);
- Advance(host, 0, 2500);
- Assert.Equal(HostRole.Host, host.Role);
-
- ulong termWhileHosting = host.Term;
-
- // A better-ranked latecomer starts up while the incumbent keeps heartbeating.
- HostElection latecomer = Node(LowId, priority: 100);
- var incumbentClaim = new HostClaim(termWhileHosting, host.Rank);
- AdvanceWithLiveHost(latecomer, 3000, 6000, HighId, incumbentClaim);
-
- Assert.Equal(HostRole.Follower, latecomer.Role);
- Assert.Equal(HighId, latecomer.CurrentHost);
-
- // And it never campaigns: a follower emits neither a claim nor a heartbeat, which is what
- // keeps the incumbent from ever hearing anything that could dethrone it. Rank at an equal
- // term only ever arises between two hosts, which is the split-brain case.
- List emitted = [];
- double lastHeartbeat = 6000;
-
- for (double now = 6000; now <= 9000; now += 50)
- {
- if (now - lastHeartbeat >= Timings.HeartbeatInterval.TotalMilliseconds)
- {
- latecomer.OnHeartbeat(At(now), HighId, incumbentClaim);
- lastHeartbeat = now;
- }
-
- ElectionEffect effect = latecomer.Tick(At(now));
- if (effect != ElectionEffect.None)
- {
- emitted.Add(effect);
- }
- }
-
- Assert.Empty(emitted);
- Assert.Equal(HostRole.Follower, latecomer.Role);
- }
-
- [Fact]
- public void OnlyAHostEverEmitsAHeartbeat()
- {
- // The invariant the incumbent-stability rule rests on. If a follower or candidate could
- // heartbeat, a better-ranked one would dethrone a healthy host at an equal term.
- HostElection node = Node(LowId);
-
- List whileFollower = Advance(node, 0, 1400);
- Assert.DoesNotContain(ElectionEffect.BroadcastHeartbeat, whileFollower);
-
- List throughCandidacy = Advance(node, 1400, 1900);
- Assert.Contains(ElectionEffect.BroadcastClaim, throughCandidacy);
- Assert.DoesNotContain(ElectionEffect.BroadcastHeartbeat, throughCandidacy);
-
- Advance(node, 1900, 2500);
- Assert.Equal(HostRole.Host, node.Role);
-
- List asHost = Advance(node, 2500, 3200);
- Assert.Contains(ElectionEffect.BroadcastHeartbeat, asHost);
- }
-
- [Fact]
- public void RankDecidesOnlyWhenTermsAreEqual()
- {
- var lowRank = new HostClaim(5, new NodeRank(0, HighId));
- var highRank = new HostClaim(5, new NodeRank(10, LowId));
-
- Assert.True(highRank.Beats(lowRank));
- Assert.False(lowRank.Beats(highRank));
-
- // A higher term wins regardless of rank -- which is what makes deliberate takeover work.
- var laterButLowerRank = new HostClaim(6, new NodeRank(0, HighId));
- Assert.True(laterButLowerRank.Beats(highRank));
- }
-
- [Fact]
- public void ManualTakeoverBeatsABetterRankedIncumbent()
- {
- // Someone sitting at a machine asking for control should get it, not be told another
- // machine outranks them.
- HostElection incumbent = Node(HighId, priority: 100);
- Advance(incumbent, 0, 2500);
- Assert.Equal(HostRole.Host, incumbent.Role);
-
- HostElection challenger = Node(LowId);
- challenger.OnHeartbeat(At(3000), HighId, new HostClaim(incumbent.Term, incumbent.Rank));
-
- Assert.Equal(ElectionEffect.BroadcastClaim, challenger.ClaimHost(At(3100)));
- Assert.True(challenger.Term > incumbent.Term);
-
- Advance(challenger, 3100, 4000);
- Assert.Equal(HostRole.Host, challenger.Role);
-
- // The incumbent stands down on seeing the higher term.
- incumbent.OnHeartbeat(At(4100), LowId, new HostClaim(challenger.Term, challenger.Rank));
- Assert.Equal(HostRole.Follower, incumbent.Role);
- Assert.Equal(LowId, incumbent.CurrentHost);
- }
-
- [Fact]
- public void SplitBrainResolvesToExactlyOneHost()
- {
- // Two partitions each elect a host; when they heal, the strict total order must leave
- // precisely one standing.
- HostElection left = Node(LowId, priority: 5);
- HostElection right = Node(HighId, priority: 5);
-
- Advance(left, 0, 2500);
- Advance(right, 0, 2500);
- Assert.Equal(HostRole.Host, left.Role);
- Assert.Equal(HostRole.Host, right.Role);
-
- // The partition heals and each sees the other's heartbeat.
- left.OnHeartbeat(At(3000), HighId, new HostClaim(right.Term, right.Rank));
- right.OnHeartbeat(At(3000), LowId, new HostClaim(left.Term, left.Rank));
-
- Assert.True(
- left.IsHost ^ right.IsHost,
- $"exactly one host expected, got left={left.Role} right={right.Role}");
- }
-
- [Fact]
- public void AYieldingHostDoesNotImmediatelyWinTheElectionItCaused()
- {
- HostElection host = Node(LowId);
- Advance(host, 0, 2500);
- Assert.Equal(HostRole.Host, host.Role);
-
- Assert.Equal(ElectionEffect.BroadcastYield, host.Yield(At(3000)));
- Assert.Equal(HostRole.Follower, host.Role);
-
- // Held out of the running while another node takes over.
- Advance(host, 3000, 5000);
- Assert.Equal(HostRole.Follower, host.Role);
-
- // Eligible again once the cooldown expires and nobody else has taken the role.
- Advance(host, 5000, 8000);
- Assert.Equal(HostRole.Host, host.Role);
- }
-
- [Fact]
- public void AYieldFromTheCurrentHostStartsAnElectionWithoutWaitingOut()
- {
- HostElection follower = Node(LowId);
- follower.OnHeartbeat(At(100), HighId, new HostClaim(3, new NodeRank(0, HighId)));
-
- follower.OnYield(At(200), HighId);
-
- // The full heartbeat timeout is not waited out, because the host said it was leaving.
- Advance(follower, 200, 900);
- Assert.Equal(HostRole.Host, follower.Role);
- }
-
- [Fact]
- public void ACandidateStandsDownForABetterClaim()
- {
- HostElection candidate = Node(LowId);
- Advance(candidate, 0, 1600);
- Assert.Equal(HostRole.Candidate, candidate.Role);
-
- candidate.OnClaim(At(1650), HighId, new HostClaim(candidate.Term + 5, new NodeRank(0, HighId)));
-
- Assert.Equal(HostRole.Follower, candidate.Role);
- Assert.Equal(HighId, candidate.CurrentHost);
-
- Advance(candidate, 1650, 2000);
- Assert.Equal(HostRole.Follower, candidate.Role);
- }
-
- [Fact]
- public void ACandidateIgnoresAnInferiorClaim()
- {
- HostElection candidate = Node(HighId, priority: 10);
- Advance(candidate, 0, 1600);
- Assert.Equal(HostRole.Candidate, candidate.Role);
-
- candidate.OnClaim(At(1650), LowId, new HostClaim(candidate.Term, new NodeRank(0, LowId)));
-
- Assert.Equal(HostRole.Candidate, candidate.Role);
-
- Advance(candidate, 1650, 2500);
- Assert.Equal(HostRole.Host, candidate.Role);
- }
-
- [Fact]
- public void AHostHeartbeatsAtTheConfiguredInterval()
- {
- HostElection host = Node(LowId);
- Advance(host, 0, 2500);
- Assert.Equal(HostRole.Host, host.Role);
-
- List effects = Advance(host, 2500, 3500, stepMs: 50);
-
- Assert.All(effects, effect => Assert.Equal(ElectionEffect.BroadcastHeartbeat, effect));
-
- // Roughly one per interval over a second, not one per tick.
- Assert.InRange(effects.Count, 3, 6);
- }
-
- ///
- /// The determinism the plan asks for: the same set of nodes must elect the same host every
- /// time, or a cluster would settle differently after each restart.
- ///
- [Theory]
- [InlineData(1)]
- [InlineData(99)]
- [InlineData(12345)]
- public void TheSameNodeSetAlwaysElectsTheSameHost(int seed)
- {
- var random = new Random(seed);
- (Guid Id, int Priority)[] candidates =
- [
- (LowId, random.Next(0, 3)),
- (MidId, random.Next(0, 3)),
- (HighId, random.Next(0, 3)),
- ];
-
- Guid? firstWinner = null;
-
- // Repeat the contest with the nodes campaigning in different orders each round.
- for (int attempt = 0; attempt < 8; attempt++)
- {
- HostElection[] nodes =
- [.. candidates.OrderBy(_ => random.Next()).Select(c => Node(c.Id, c.Priority))];
-
- foreach (HostElection node in nodes)
- {
- Advance(node, 0, 1600);
- }
-
- // Everyone hears everyone else's claim.
- foreach (HostElection node in nodes)
- {
- foreach (HostElection other in nodes.Where(n => !ReferenceEquals(n, node)))
- {
- node.OnClaim(At(1650), other.Rank.NodeId, new HostClaim(other.Term, other.Rank));
- }
- }
-
- foreach (HostElection node in nodes)
- {
- Advance(node, 1650, 2500);
- }
-
- HostElection[] hosts = [.. nodes.Where(node => node.IsHost)];
- Assert.Single(hosts);
-
- firstWinner ??= hosts[0].Rank.NodeId;
- Assert.Equal(firstWinner, hosts[0].Rank.NodeId);
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs
deleted file mode 100644
index c22f14e..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/HotkeyMatcherTests.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-using VirtualKmSwitch.InputEngine;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class HotkeyMatcherTests
-{
- private const ushort LCtrl = 0xA2;
- private const ushort RCtrl = 0xA3;
- private const ushort LAlt = 0xA4;
- private const ushort LShift = 0xA0;
- private const ushort Key1 = 0x31;
- private const ushort Key0 = 0x30;
- private const ushort Home = 0x24;
- private const ushort Escape = 0x1B;
- private const ushort KeyA = 0x41;
-
- private static HotkeyMatch Press(HotkeyMatcher matcher, ushort key, out bool swallow) =>
- matcher.Process(key, isUp: false, out swallow);
-
- private static void Hold(HotkeyMatcher matcher, params ushort[] keys)
- {
- foreach (ushort key in keys)
- {
- matcher.Process(key, isUp: false, out _);
- }
- }
-
- [Fact]
- public void CtrlAltDigitEngagesTheMatchingSlot()
- {
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
-
- HotkeyMatch match = Press(matcher, Key1, out bool swallow);
-
- Assert.Equal(HotkeyActionKind.Engage, match.Kind);
- Assert.Equal(1, match.Slot);
- Assert.True(swallow, "the trigger key must not reach the local machine");
- }
-
- [Fact]
- public void CtrlAltZeroReturnsControlLocally()
- {
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
-
- Assert.Equal(HotkeyActionKind.ReturnLocal, Press(matcher, Key0, out _).Kind);
- }
-
- [Fact]
- public void CtrlAltHomeClaimsHost()
- {
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
-
- Assert.Equal(HotkeyActionKind.ClaimHost, Press(matcher, Home, out _).Kind);
- }
-
- [Fact]
- public void PanicRequiresShiftAsWell()
- {
- var matcher = new HotkeyMatcher();
-
- // Ctrl+Alt+Esc is not the panic chord and must not fire it.
- Hold(matcher, LCtrl, LAlt);
- Assert.False(Press(matcher, Escape, out _).IsMatch);
-
- Hold(matcher, LShift);
- Assert.Equal(HotkeyActionKind.Panic, Press(matcher, Escape, out _).Kind);
- }
-
- [Fact]
- public void EitherSideOfAModifierPairCounts()
- {
- var matcher = new HotkeyMatcher();
- Hold(matcher, RCtrl, LAlt);
-
- Assert.Equal(HotkeyActionKind.Engage, Press(matcher, Key1, out _).Kind);
- }
-
- [Fact]
- public void ReleasingOneOfAPairKeepsTheModifierHeld()
- {
- // Holding both controls and releasing one must not clear Control, or a chord typed with
- // both hands would silently stop matching.
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, RCtrl, LAlt);
-
- matcher.Process(LCtrl, isUp: true, out _);
-
- Assert.True(matcher.CurrentModifiers.HasFlag(Modifiers.Control));
- Assert.Equal(HotkeyActionKind.Engage, Press(matcher, Key1, out _).Kind);
- }
-
- [Fact]
- public void ExtraModifiersPreventAMatch()
- {
- // Ctrl+Alt+Shift+1 is not Ctrl+Alt+1. Matching it would hijack chords belonging to other
- // applications.
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt, LShift);
-
- Assert.False(Press(matcher, Key1, out bool swallow).IsMatch);
- Assert.False(swallow);
- }
-
- [Fact]
- public void PlainKeysAreNeitherMatchedNorSwallowed()
- {
- var matcher = new HotkeyMatcher();
-
- Assert.False(Press(matcher, KeyA, out bool swallow).IsMatch);
- Assert.False(swallow);
-
- Hold(matcher, LCtrl, LAlt);
- Assert.False(Press(matcher, KeyA, out swallow).IsMatch);
- Assert.False(swallow);
- }
-
- [Fact]
- public void ModifierKeysThemselvesPassThrough()
- {
- // Swallowing modifiers would break every ordinary shortcut on the machine while idle.
- var matcher = new HotkeyMatcher();
-
- Press(matcher, LCtrl, out bool swallow);
- Assert.False(swallow);
-
- Press(matcher, LAlt, out swallow);
- Assert.False(swallow);
- }
-
- [Fact]
- public void ReleaseOfASwallowedTriggerIsAlsoSwallowed()
- {
- // Otherwise the application underneath sees a key-up with no matching key-down, which some
- // apps treat as a stuck key.
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
- Press(matcher, Key1, out _);
-
- matcher.Process(Key1, isUp: true, out bool swallow);
- Assert.True(swallow);
-
- // Only the one release is consumed, not every subsequent release of that key.
- matcher.Process(Key1, isUp: true, out swallow);
- Assert.False(swallow);
- }
-
- [Fact]
- public void ResetForgetsHeldModifiers()
- {
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
-
- matcher.Reset();
-
- Assert.Equal(Modifiers.None, matcher.CurrentModifiers);
- Assert.False(Press(matcher, Key1, out _).IsMatch);
- }
-
- [Fact]
- public void MatchingAllocatesNothing()
- {
- // Runs inside the hook callback, where a GC pause risks the LowLevelHooksTimeout.
- var matcher = new HotkeyMatcher();
- Hold(matcher, LCtrl, LAlt);
- Press(matcher, Key1, out _);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- matcher.Process(Key1, isUp: false, out _);
- matcher.Process(Key1, isUp: true, out _);
- matcher.Process(KeyA, isUp: false, out _);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs
deleted file mode 100644
index fef3a0b..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/InputCoalescerTests.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class InputCoalescerTests
-{
- private static int Run(InputEvent[] source, out InputEvent[] result)
- {
- var destination = new InputEvent[source.Length];
- int count = InputCoalescer.Coalesce(source, destination);
- result = destination[..count];
- return count;
- }
-
- [Fact]
- public void ConsecutiveMotionMergesIntoOneEvent()
- {
- InputEvent[] source =
- [
- InputEvent.MouseMove(10, 3, 1),
- InputEvent.MouseMove(20, 4, 2),
- InputEvent.MouseMove(30, -1, 3),
- ];
-
- Assert.Equal(1, Run(source, out InputEvent[] result));
- Assert.Equal(6, result[0].X);
- Assert.Equal(6, result[0].Y);
-
- // The earliest timestamp survives, so latency is measured from when the movement began
- // rather than when the batch happened to be flushed.
- Assert.Equal(10, result[0].Timestamp);
- }
-
- [Fact]
- public void ButtonsAndKeysEndTheRun()
- {
- // A click must stay after the motion that preceded it, or a drag lands somewhere else.
- InputEvent[] source =
- [
- InputEvent.MouseMove(1, 5, 0),
- InputEvent.MouseMove(2, 5, 0),
- InputEvent.MouseButton(3, 0, up: false),
- InputEvent.MouseMove(4, 7, 0),
- InputEvent.MouseButton(5, 0, up: true),
- ];
-
- Assert.Equal(4, Run(source, out InputEvent[] result));
- Assert.Equal(InputEventKind.MouseMove, result[0].Kind);
- Assert.Equal(10, result[0].X);
- Assert.Equal(InputEventKind.MouseButton, result[1].Kind);
- Assert.False(result[1].IsUp);
- Assert.Equal(7, result[2].X);
- Assert.True(result[3].IsUp);
- }
-
- [Fact]
- public void KeyEventsAreNeverMergedOrDropped()
- {
- // Losing a key-up strands a modifier held down on a remote machine, which is the one
- // failure that cannot be recovered from the far end.
- InputEvent[] source =
- [
- InputEvent.Key(1, 0x2A, InputEventFlags.None),
- InputEvent.Key(2, 0x1E, InputEventFlags.None),
- InputEvent.Key(3, 0x1E, InputEventFlags.Up),
- InputEvent.Key(4, 0x2A, InputEventFlags.Up),
- ];
-
- Assert.Equal(4, Run(source, out InputEvent[] result));
- Assert.Equal(source, result);
- }
-
- [Fact]
- public void MotionThatCancelsOutIsDropped()
- {
- // A net-zero run is not movement; sending it would make the far side inject nothing at
- // some cost.
- InputEvent[] source =
- [
- InputEvent.MouseMove(1, 5, 5),
- InputEvent.MouseMove(2, -5, -5),
- ];
-
- Assert.Equal(0, Run(source, out _));
- }
-
- [Fact]
- public void ACancellingRunBetweenClicksStillPreservesTheClicks()
- {
- InputEvent[] source =
- [
- InputEvent.MouseButton(1, 0, up: false),
- InputEvent.MouseMove(2, 3, 0),
- InputEvent.MouseMove(3, -3, 0),
- InputEvent.MouseButton(4, 0, up: true),
- ];
-
- Assert.Equal(2, Run(source, out InputEvent[] result));
- Assert.All(result, e => Assert.Equal(InputEventKind.MouseButton, e.Kind));
- }
-
- [Fact]
- public void AccumulatedMotionSaturatesRatherThanWrapping()
- {
- // Wrapping would send the pointer hard in the opposite direction, which is far worse than
- // clamping at a distance no screen is that wide.
- InputEvent[] source =
- [
- InputEvent.MouseMove(1, int.MaxValue, 0),
- InputEvent.MouseMove(2, int.MaxValue, 0),
- ];
-
- Assert.Equal(1, Run(source, out InputEvent[] result));
- Assert.Equal(int.MaxValue, result[0].X);
- }
-
- [Fact]
- public void OtherEventKindsPassThroughUntouched()
- {
- InputEvent[] source =
- [
- InputEvent.MouseWheel(1, 120, horizontal: false),
- InputEvent.ReleaseAll(2),
- ];
-
- Assert.Equal(2, Run(source, out InputEvent[] result));
- Assert.Equal(InputEventKind.MouseWheel, result[0].Kind);
- Assert.Equal(InputEventKind.ReleaseAll, result[1].Kind);
- }
-
- [Fact]
- public void EmptyInputProducesNothing() =>
- Assert.Equal(0, InputCoalescer.Coalesce([], new InputEvent[4]));
-
- [Fact]
- public void CoalescingAllocatesNothing()
- {
- var source = new InputEvent[64];
- for (int i = 0; i < source.Length; i++)
- {
- source[i] = i % 8 == 7
- ? InputEvent.Key(i, 0x1E, InputEventFlags.None)
- : InputEvent.MouseMove(i, 1, 1);
- }
-
- var destination = new InputEvent[64];
- InputCoalescer.Coalesce(source, destination);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- InputCoalescer.Coalesce(source, destination);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-
- [Fact]
- public void MotionHeavyTrafficCompressesSharply()
- {
- // The realistic case: a high-polling-rate mouse produces long runs of tiny deltas, and
- // this is where batching earns its keep on the wire.
- var source = new InputEvent[1000];
- for (int i = 0; i < source.Length; i++)
- {
- source[i] = InputEvent.MouseMove(i, 1, 0);
- }
-
- Assert.Equal(1, Run(source, out InputEvent[] result));
- Assert.Equal(1000, result[0].X);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs
deleted file mode 100644
index 66f4c44..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/InputRouterTests.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-using Microsoft.Extensions.Logging.Abstractions;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Service;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// Which machine a captured keystroke goes to.
-///
-/// The whole product is this decision made correctly a few thousand times a second, and both ways
-/// of getting it wrong are bad in ways a user cannot recover from: input that leaks to the local
-/// machine while they believe they are driving a remote one, or a modifier left held down on a
-/// machine they have stopped driving.
-///
-public class InputRouterTests
-{
- private sealed class FakeSender : IPeerSender
- {
- private readonly List<(Guid Peer, InputEvent Event)> _sent = [];
-
- /// Peers this sender will refuse, standing in for a queue that has backed up.
- public HashSet Refusing { get; } = [];
-
- public HashSet Connected { get; } = [];
-
- public IReadOnlyList<(Guid Peer, InputEvent Event)> Sent => _sent;
-
- public bool TrySend(Guid peerId, in InputEvent inputEvent)
- {
- if (Refusing.Contains(peerId))
- {
- return false;
- }
-
- _sent.Add((peerId, inputEvent));
- return true;
- }
-
- public bool IsConnected(Guid peerId) => Connected.Contains(peerId);
- }
-
- private static InputRouter Create(FakeSender sender)
- {
- var router = new InputRouter(
- new AgentSupervisor(NullLogger.Instance),
- NullLogger.Instance);
-
- router.Sender = sender;
- return router;
- }
-
- [Fact]
- public void WithNothingEngagedNothingLeavesTheMachine()
- {
- // The default state, and the one a bug here would be most damaging in: an idle machine
- // that quietly forwards its owner's typing somewhere.
- var sender = new FakeSender();
- InputRouter router = Create(sender);
-
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
- router.Capture(InputEvent.MouseMove(2, 5, 5));
-
- Assert.Empty(sender.Sent);
- Assert.False(router.IsEngaged);
- Assert.Equal(Guid.Empty, router.Target);
- }
-
- [Fact]
- public void WhileEngagedEveryCapturedEventGoesToThatMachine()
- {
- var sender = new FakeSender();
- InputRouter router = Create(sender);
- var peer = Guid.NewGuid();
-
- router.Engage(peer);
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
- router.Capture(InputEvent.MouseMove(2, 5, 5));
- router.Capture(InputEvent.Key(3, 30, InputEventFlags.Up));
-
- Assert.Equal(3, sender.Sent.Count);
- Assert.All(sender.Sent, entry => Assert.Equal(peer, entry.Peer));
- Assert.Equal(3, router.SentToPeer);
- }
-
- [Fact]
- public void EngagingASecondMachineRedirectsRatherThanDuplicating()
- {
- var sender = new FakeSender();
- InputRouter router = Create(sender);
- var first = Guid.NewGuid();
- var second = Guid.NewGuid();
-
- router.Engage(first);
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
-
- router.Engage(second);
- router.Capture(InputEvent.Key(2, 31, InputEventFlags.None));
-
- Assert.Equal(first, sender.Sent[0].Peer);
- Assert.Equal(second, sender.Sent[1].Peer);
- Assert.Equal(second, router.Target);
- }
-
- [Fact]
- public void DisengagingReleasesEverythingHeldOnTheMachineBeingLeft()
- {
- // The single most common way a KVM breaks: a modifier is down when control moves away, and
- // the machine left behind has no way to work out that it should let go.
- var sender = new FakeSender();
- InputRouter router = Create(sender);
- var peer = Guid.NewGuid();
-
- router.Engage(peer);
- router.Capture(InputEvent.Key(1, 42, InputEventFlags.None));
- router.Disengage();
-
- (Guid Peer, InputEvent Event) last = sender.Sent[^1];
-
- Assert.Equal(peer, last.Peer);
- Assert.Equal(InputEventKind.ReleaseAll, last.Event.Kind);
- Assert.False(router.IsEngaged);
- }
-
- [Fact]
- public void DisengagingWhenNothingIsEngagedIsHarmless()
- {
- // Reached by a person who is not sure what state they are in, which is exactly when it
- // must not throw or send anything.
- var sender = new FakeSender();
- InputRouter router = Create(sender);
-
- router.Disengage();
- router.Disengage();
-
- Assert.Empty(sender.Sent);
- }
-
- [Fact]
- public void OnceDisengagedInputStaysOnThisMachine()
- {
- var sender = new FakeSender();
- InputRouter router = Create(sender);
- var peer = Guid.NewGuid();
-
- router.Engage(peer);
- router.Disengage();
-
- int afterRelease = sender.Sent.Count;
- router.Capture(InputEvent.Key(9, 30, InputEventFlags.None));
-
- Assert.Equal(afterRelease, sender.Sent.Count);
- }
-
- [Fact]
- public void ATargetThatCannotTakeAKeyEventIsReported()
- {
- var sender = new FakeSender();
- InputRouter router = Create(sender);
- var peer = Guid.NewGuid();
- sender.Refusing.Add(peer);
-
- Guid? stalled = null;
- router.TargetStalled += id => stalled = id;
-
- router.Engage(peer);
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
-
- // Surfaced rather than counted. The listener disengages, which is the only response that
- // does not leave the remote machine in a state nothing can clear.
- Assert.Equal(peer, stalled);
- Assert.Equal(1, router.RefusedByPeer);
- }
-
- [Fact]
- public void WithNoMeshAttachedEngagingIsInertRatherThanFatal()
- {
- // The service starts before the mesh does, and a hotkey pressed in that window must not
- // take the router down.
- var router = new InputRouter(
- new AgentSupervisor(NullLogger.Instance),
- NullLogger.Instance);
-
- router.Engage(Guid.NewGuid());
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
- router.Disengage();
-
- Assert.False(router.IsEngaged);
- }
-
- [Fact]
- public void CapturedEventsAreStillReportedLocallyWhileEngaged()
- {
- // Diagnostics must not go dark the moment the machine starts driving another one.
- var sender = new FakeSender();
- InputRouter router = Create(sender);
-
- int seen = 0;
- router.CapturedLocally += _ => seen++;
-
- router.Engage(Guid.NewGuid());
- router.Capture(InputEvent.Key(1, 30, InputEventFlags.None));
- router.Capture(InputEvent.Key(2, 30, InputEventFlags.Up));
-
- Assert.Equal(2, seen);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs
deleted file mode 100644
index 9336a28..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/JoinKeyTests.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-using VirtualKmSwitch.Ipc;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class JoinKeyTests
-{
- [Fact]
- public void AKeyIsGroupedForReadingAloud()
- {
- string key = JoinKey.Create();
-
- Assert.Equal("XXXX-XXXX-XXXX-XXXX".Length, key.Length);
- Assert.Equal(4, key.Split('-').Length);
- Assert.All(key.Split('-'), group => Assert.Equal(4, group.Length));
- }
-
- [Theory]
- [InlineData('I')]
- [InlineData('O')]
- [InlineData('0')]
- [InlineData('1')]
- public void TheAlphabetOmitsCharactersThatAreMisreadOffAScreen(char excluded)
- {
- // Someone is going to read this out loud to someone in another room. A key containing
- // both O and 0 turns that into a guessing game, and the failure looks like a wrong key.
- for (int attempt = 0; attempt < 200; attempt++)
- {
- Assert.DoesNotContain(excluded, JoinKey.Create());
- }
- }
-
- [Fact]
- public void KeysDoNotRepeat()
- {
- var seen = new HashSet();
-
- for (int attempt = 0; attempt < 500; attempt++)
- {
- Assert.True(seen.Add(JoinKey.Create()));
- }
- }
-
- [Theory]
- [InlineData("ABCD-EFGH-JKLM-NPQR")]
- [InlineData("abcd-efgh-jklm-npqr")]
- [InlineData("ABCDEFGHJKLMNPQR")]
- [InlineData("ABCD EFGH JKLM NPQR")]
- [InlineData(" ABCD-efgh JKLM-npqr ")]
- public void TranscriptionDifferencesDoNotChangeTheKey(string typed)
- {
- // Case, spaces and missing dashes are how people actually type a key they were told over
- // the phone; none of them should be the difference between joining and being refused.
- Assert.Equal("ABCDEFGHJKLMNPQR", JoinKey.Normalize(typed));
- }
-
- [Fact]
- public void GenuinelyDifferentKeysStayDifferent()
- {
- Assert.NotEqual(JoinKey.Normalize("ABCD-EFGH-JKLM-NPQR"), JoinKey.Normalize("ABCD-EFGH-JKLM-NPQS"));
- }
-
- [Fact]
- public void EmptyInputNormalisesToEmptyRatherThanThrowing()
- {
- Assert.Equal(string.Empty, JoinKey.Normalize(string.Empty));
- Assert.Equal(string.Empty, JoinKey.Normalize(" "));
- }
-
- [Fact]
- public void TheClusterIdIsStableAcrossHowTheKeyWasTyped()
- {
- // The beacon advertises this id, so two machines holding the same key must derive the
- // same one regardless of who typed it and how.
- string fromCreator = EnrollmentHandshake.ClusterIdFor(JoinKey.Normalize("ABCD-EFGH-JKLM-NPQR"));
- string fromJoiner = EnrollmentHandshake.ClusterIdFor(JoinKey.Normalize("abcd efgh jklm npqr"));
-
- Assert.Equal(fromCreator, fromJoiner);
- }
-}
-
-public class ClusterViewTests
-{
- [Fact]
- public void AMachineWithNoNameFallsBackToItsIdRatherThanAnInventedName()
- {
- // "Machine 2" would read as information when it is really just a position in a list.
- var machine = new MachineInfo { NodeId = Guid.NewGuid() };
-
- Assert.Equal(machine.NodeId.ToString("N")[..12], machine.Label);
- }
-
- [Fact]
- public void ANamedMachineShowsItsName()
- {
- var machine = new MachineInfo { NodeId = Guid.NewGuid(), DisplayName = "Workshop" };
-
- Assert.Equal("Workshop", machine.Label);
- }
-
- [Fact]
- public void AViewSurvivesTheRoundTripThroughAControlResponse()
- {
- var view = new ClusterView
- {
- HasCluster = true,
- ClusterId = "0123456789ABCDEF",
- ClusterName = "Home",
- LocalNodeId = Guid.NewGuid(),
- Health = ClusterHealth.RecoveredFromBackup,
- HealthDetail = "primary was empty",
- Machines = [new MachineInfo { NodeId = Guid.NewGuid(), DisplayName = "Desk", IsLocal = true }],
- };
-
- ControlResponse response = ControlResponse.Ok(new Dictionary
- {
- [ClusterView.ValueKey] = view.ToJson(),
- });
-
- ClusterView? decoded = ClusterView.FromResponse(response);
-
- Assert.NotNull(decoded);
- Assert.Equal(view.ClusterName, decoded.ClusterName);
- Assert.Equal(view.LocalNodeId, decoded.LocalNodeId);
- Assert.Equal(ClusterHealth.RecoveredFromBackup, decoded.Health);
- Assert.Equal("Desk", Assert.Single(decoded.Machines).DisplayName);
- }
-
- [Fact]
- public void TheJoinKeyIsAbsentUnlessDeliberatelyIncluded()
- {
- ClusterView view = new() { HasCluster = true };
-
- Assert.Null(ClusterView.FromResponse(
- ControlResponse.Ok(new Dictionary { [ClusterView.ValueKey] = view.ToJson() }))?.JoinKey);
- }
-
- [Fact]
- public void AResponseCarryingNoViewDecodesToNothingRatherThanAnEmptyCluster()
- {
- // An empty cluster and "the service did not answer that question" look identical on
- // screen, and only one of them means every peer has left.
- Assert.Null(ClusterView.FromResponse(ControlResponse.Ok()));
- Assert.Null(ClusterView.FromResponse(ControlResponse.Fail("nope")));
- }
-
- [Fact]
- public void MalformedContentDecodesToNothingRatherThanThrowing()
- {
- ControlResponse response = ControlResponse.Ok(new Dictionary
- {
- [ClusterView.ValueKey] = "{ not json",
- });
-
- Assert.Null(ClusterView.FromResponse(response));
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs
deleted file mode 100644
index 4715643..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/KeyboardTranslatorTests.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.InputEngine;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class KeyboardTranslatorTests
-{
- private const uint Down = KeyboardTranslator.WM_KEYDOWN;
- private const uint Up = KeyboardTranslator.WM_KEYUP;
- private const uint SysDown = KeyboardTranslator.WM_SYSKEYDOWN;
- private const uint SysUp = KeyboardTranslator.WM_SYSKEYUP;
- private const uint Extended = KeyboardTranslator.LLKHF_EXTENDED;
-
- [Theory]
- [InlineData(Down, false)]
- [InlineData(SysDown, false)]
- [InlineData(Up, true)]
- [InlineData(SysUp, true)]
- public void KeyTransitionDirectionIsTaggedFromTheMessage(uint message, bool expectUp)
- {
- Assert.True(KeyboardTranslator.TryTranslate(message, 0x41, 0x1E, 0, 100, out InputEvent e));
- Assert.Equal(expectUp, e.IsUp);
- Assert.Equal(0x1E, e.Code);
- Assert.Equal(100, e.Timestamp);
- }
-
- [Fact]
- public void ExtendedKeysAreFlaggedRatherThanPrefixed()
- {
- // Right Ctrl: same scancode as left, distinguished only by the E0 prefix. The prefix must
- // travel as a flag, never packed into the scancode itself.
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0xA3, 0x1D, Extended, 1, out InputEvent e));
- Assert.Equal(0x1D, e.Code);
- Assert.True((e.Flags & InputEventFlags.Extended) != 0);
- }
-
- [Theory]
- [InlineData(0x2A)] // left shift
- [InlineData(0x36)] // right shift
- public void FakeShiftIsDropped(ushort scanCode)
- {
- // The driver brackets grey navigation keys with synthetic shifts when Shift or NumLock is
- // active. Forwarding them leaves shift stuck or double-toggled on the target.
- Assert.False(KeyboardTranslator.TryTranslate(Down, 0xA0, scanCode, Extended, 1, out _));
- }
-
- [Theory]
- [InlineData(0x2A)]
- [InlineData(0x36)]
- public void RealShiftIsKept(ushort scanCode)
- {
- // A genuine shift never carries the extended flag, which is what makes the filter exact.
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0xA0, scanCode, 0, 1, out InputEvent e));
- Assert.Equal(scanCode, e.Code);
- }
-
- [Fact]
- public void PauseIsSentAsVirtualKeyBecauseItsScancodeCannotBeReplayed()
- {
- // Pause emits E1 1D 45 and reports no release at all, so scancode replay cannot reproduce
- // it. It travels as a virtual key and the target synthesises the key-up.
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0x13, 0x45, 0, 1, out InputEvent e));
- Assert.True((e.Flags & InputEventFlags.VirtualKey) != 0);
- Assert.Equal(0x13, e.Code);
- }
-
- [Fact]
- public void CtrlBreakIsSentAsVirtualKey()
- {
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0x03, 0x46, Extended, 1, out InputEvent e));
- Assert.True((e.Flags & InputEventFlags.VirtualKey) != 0);
- Assert.Equal(0x03, e.Code);
- }
-
- [Fact]
- public void NumLockIsNotConfusedWithPause()
- {
- // Both report scancode 0x45. Keying on the scancode alone would emit a spurious NumLock
- // toggle on every Pause press; the virtual key is what separates them.
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0x90, 0x45, 0, 1, out InputEvent numLock));
- Assert.False((numLock.Flags & InputEventFlags.VirtualKey) != 0);
- Assert.Equal(0x45, numLock.Code);
-
- Assert.True(KeyboardTranslator.TryTranslate(Down, 0x13, 0x45, 0, 1, out InputEvent pause));
- Assert.True((pause.Flags & InputEventFlags.VirtualKey) != 0);
- }
-
- [Fact]
- public void KeyboardOverrunSentinelIsDropped()
- {
- Assert.False(KeyboardTranslator.TryTranslate(Down, 0xFF, 0xFF, 0, 1, out _));
- }
-
- [Fact]
- public void NonKeyboardMessagesAreIgnored()
- {
- Assert.False(KeyboardTranslator.TryTranslate(0x0200 /* WM_MOUSEMOVE */, 0, 0, 0, 1, out _));
- }
-
- [Fact]
- public void TranslationAllocatesNothing()
- {
- // Runs on the hook thread, which is on a hard latency budget: a GC pause there risks
- // overrunning LowLevelHooksTimeout, after which Windows unhooks us silently.
- KeyboardTranslator.TryTranslate(Down, 0x41, 0x1E, 0, 1, out _);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- KeyboardTranslator.TryTranslate(Down, 0x41, 0x1E, 0, i, out _);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs
deleted file mode 100644
index b11fe5f..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/LocalSettingsTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class LocalSettingsTests : IDisposable
-{
- private readonly string _directory =
- Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N"));
-
- private static readonly Guid MachineOne = Guid.NewGuid();
- private static readonly Guid MachineTwo = Guid.NewGuid();
-
- private LocalSettingsStore CreateStore() => new(Path.Combine(_directory, "local.json"));
-
- [Fact]
- public void AbsentSettingsLoadAsDefaultsRatherThanFailing()
- {
- LocalSettingsDocument settings = CreateStore().Load();
-
- Assert.Empty(settings.Hotkeys);
- Assert.True(settings.ClipboardSyncEnabled);
- }
-
- [Fact]
- public void BindingsRoundTrip()
- {
- LocalSettingsStore store = CreateStore();
- LocalSettingsDocument settings = store.Bind(store.Load(), 3, MachineOne);
- store.Save(settings);
-
- LocalSettingsDocument reloaded = store.Load();
-
- Assert.Equal(MachineOne, LocalSettingsStore.TargetFor(reloaded, 3));
- }
-
- [Fact]
- public void BindingAMachineAlreadyBoundMovesItRatherThanDuplicating()
- {
- // Otherwise a machine would appear under two keys that both claim to drive it, and which
- // one actually did would depend on iteration order.
- LocalSettingsStore store = CreateStore();
-
- LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne);
- settings = store.Bind(settings, 5, MachineOne);
-
- Assert.Null(LocalSettingsStore.TargetFor(settings, 1));
- Assert.Equal(MachineOne, LocalSettingsStore.TargetFor(settings, 5));
- Assert.Single(settings.Hotkeys);
- }
-
- [Fact]
- public void BindingAnEmptyTargetClearsTheSlot()
- {
- LocalSettingsStore store = CreateStore();
-
- LocalSettingsDocument settings = store.Bind(store.Load(), 2, MachineOne);
- settings = store.Bind(settings, 2, Guid.Empty);
-
- Assert.Null(LocalSettingsStore.TargetFor(settings, 2));
- }
-
- [Theory]
- [InlineData(0)]
- [InlineData(10)]
- [InlineData(-1)]
- public void OnlySlotsOneToNineAreBindable(int slot)
- {
- // Slot 0 returns control to this machine and is fixed; binding it would take away the way
- // back.
- LocalSettingsStore store = CreateStore();
-
- Assert.Throws(() => store.Bind(store.Load(), slot, MachineOne));
- }
-
- [Fact]
- public void ARemovedMachineLeavesItsSlotEmptyRatherThanShiftingOthersUp()
- {
- // Silently rebinding a familiar key to a different machine is the worst outcome available:
- // the keystrokes land somewhere unexpected and nothing explains why.
- LocalSettingsStore store = CreateStore();
-
- LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne);
- settings = store.Bind(settings, 2, MachineTwo);
-
- settings = store.PruneRemoved(settings, new HashSet { MachineTwo });
-
- Assert.Null(LocalSettingsStore.TargetFor(settings, 1));
- Assert.Equal(MachineTwo, LocalSettingsStore.TargetFor(settings, 2));
- }
-
- [Fact]
- public void ANewMachineTakesTheLowestFreeSlot()
- {
- LocalSettingsStore store = CreateStore();
-
- Assert.Equal(1, LocalSettingsStore.NextFreeSlot(store.Load()));
-
- LocalSettingsDocument settings = store.Bind(store.Load(), 1, MachineOne);
- Assert.Equal(2, LocalSettingsStore.NextFreeSlot(settings));
-
- settings = store.Bind(settings, 3, MachineTwo);
- Assert.Equal(2, LocalSettingsStore.NextFreeSlot(settings));
- }
-
- [Fact]
- public void AllSlotsTakenReportsNoneRatherThanAnInvalidSlot()
- {
- LocalSettingsStore store = CreateStore();
- LocalSettingsDocument settings = store.Load();
-
- for (int slot = LocalSettingsStore.MinSlot; slot <= LocalSettingsStore.MaxSlot; slot++)
- {
- settings = store.Bind(settings, slot, Guid.NewGuid());
- }
-
- Assert.Null(LocalSettingsStore.NextFreeSlot(settings));
- }
-
- [Fact]
- public void CorruptSettingsFallBackToDefaults()
- {
- // Unlike the roster, nothing here decides who is trusted, so losing it is an inconvenience
- // rather than a reason to refuse to start.
- LocalSettingsStore store = CreateStore();
- store.Save(store.Bind(store.Load(), 1, MachineOne));
-
- File.WriteAllText(store.FilePath, "{ not json");
-
- Assert.Empty(store.Load().Hotkeys);
- }
-
- [Fact]
- public void UnknownFieldsSurviveARoundTrip()
- {
- LocalSettingsStore store = CreateStore();
- store.Save(store.Bind(store.Load(), 1, MachineOne));
-
- string json = File.ReadAllText(store.FilePath);
- json = json.TrimEnd().TrimEnd('}') + ", \"futureOption\": true }";
- File.WriteAllText(store.FilePath, json);
-
- LocalSettingsDocument loaded = store.Load();
- Assert.NotNull(loaded.Extra);
-
- store.Save(loaded);
- Assert.Contains("futureOption", File.ReadAllText(store.FilePath));
- }
-
- public void Dispose()
- {
- try
- {
- if (Directory.Exists(_directory))
- {
- Directory.Delete(_directory, recursive: true);
- }
- }
- catch
- {
- // Best effort.
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs
deleted file mode 100644
index 7309830..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/MeshRoutingTests.cs
+++ /dev/null
@@ -1,261 +0,0 @@
-using System.Diagnostics;
-using System.Security.Cryptography.X509Certificates;
-using Microsoft.Extensions.Logging.Abstractions;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Service;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// A keystroke captured on one machine arriving on another, over a real encrypted link.
-///
-/// This composes the same pieces the service composes -- a router, a mesh, and the adapter between
-/// them -- rather than mocking the join between them, because that join is the part that has never
-/// run before and is where the wiring can be wrong while every component test still passes.
-///
-public class MeshRoutingTests
-{
- /// The adapter is, reduced to the part under test.
- private sealed class MeshSender(PeerMesh mesh) : IPeerSender
- {
- public bool TrySend(Guid peerId, in InputEvent inputEvent) => mesh.TrySend(peerId, in inputEvent);
-
- public bool IsConnected(Guid peerId) => mesh.Connection(peerId) is not null;
- }
-
- private sealed class TwoMachines : IAsyncDisposable
- {
- public TwoMachines()
- {
- AliceCertificate = NodeCertificate.Create("CN=RouteAlice", StoreLocation.CurrentUser);
- BobCertificate = NodeCertificate.Create("CN=RouteBob", StoreLocation.CurrentUser);
-
- // Ordered so Alice is the dialler, matching the lower-id-dials rule.
- Guid first = Guid.NewGuid();
- Guid second = Guid.NewGuid();
- (AliceId, BobId) = first.CompareTo(second) < 0 ? (first, second) : (second, first);
-
- var alicePins = new PinStore();
- var bobPins = new PinStore();
- alicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId);
- bobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId);
-
- int port = Random.Shared.Next(45_000, 60_000);
- AlicePort = port;
- BobPort = port + 1;
-
- AliceMesh = new PeerMesh(AliceId, AliceCertificate, alicePins, AlicePort);
- BobMesh = new PeerMesh(BobId, BobCertificate, bobPins, BobPort);
-
- AliceRouter = new InputRouter(
- new AgentSupervisor(NullLogger.Instance),
- NullLogger.Instance)
- {
- Sender = new MeshSender(AliceMesh),
- };
-
- BobMesh.InputReceived += (_, batch, _) =>
- {
- lock (Received)
- {
- foreach (InputEvent inputEvent in batch)
- {
- Received.Add(inputEvent);
- }
- }
- };
- }
-
- public X509Certificate2 AliceCertificate { get; }
-
- public X509Certificate2 BobCertificate { get; }
-
- public Guid AliceId { get; }
-
- public Guid BobId { get; }
-
- public int AlicePort { get; }
-
- public int BobPort { get; }
-
- public PeerMesh AliceMesh { get; }
-
- public PeerMesh BobMesh { get; }
-
- public InputRouter AliceRouter { get; }
-
- /// What actually landed on Bob.
- public List Received { get; } = [];
-
- public InputEvent[] Snapshot()
- {
- lock (Received)
- {
- return [.. Received];
- }
- }
-
- public async Task ConnectAsync()
- {
- AliceMesh.Start();
- BobMesh.Start();
-
- Assert.True(await AliceMesh.EnsureConnectedAsync(BobId, "127.0.0.1", BobPort));
- await WaitForAsync(() => BobMesh.ConnectionCount > 0, "Bob to see the link");
- }
-
- public async ValueTask DisposeAsync()
- {
- await AliceMesh.DisposeAsync();
- await BobMesh.DisposeAsync();
- AliceCertificate.Dispose();
- BobCertificate.Dispose();
- }
- }
-
- private static async Task WaitForAsync(Func condition, string what)
- {
- var deadline = Stopwatch.StartNew();
- while (deadline.Elapsed < TimeSpan.FromSeconds(15))
- {
- if (condition())
- {
- return;
- }
-
- await Task.Delay(10);
- }
-
- Assert.Fail("timed out waiting for " + what);
- }
-
- [Fact]
- public async Task AKeystrokeCapturedWhileEngagedArrivesOnTheMachineBeingDriven()
- {
- await using var machines = new TwoMachines();
- await machines.ConnectAsync();
-
- machines.AliceRouter.Engage(machines.BobId);
-
- machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None));
- machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.Up));
-
- await WaitForAsync(() => machines.Snapshot().Length >= 2, "both key transitions to arrive");
-
- InputEvent[] received = machines.Snapshot();
-
- Assert.Equal(InputEventKind.Key, received[0].Kind);
- Assert.Equal(30, received[0].Code);
- Assert.False(received[0].IsUp);
-
- // The release matters as much as the press. It is the half that, when lost, leaves a key
- // held down on a machine the user cannot reach from where they are sitting.
- Assert.True(received[1].IsUp);
- }
-
- [Fact]
- public async Task NothingCrossesTheLinkUntilTheMachineIsEngaged()
- {
- // The link exists from the moment both machines are members. Being connected must not be
- // the same as being driven, or every peer would type into every other one.
- await using var machines = new TwoMachines();
- await machines.ConnectAsync();
-
- machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None));
- await Task.Delay(250);
-
- Assert.Empty(machines.Snapshot());
- }
-
- [Fact]
- public async Task DisengagingSendsTheReleaseBeforeControlComesBack()
- {
- await using var machines = new TwoMachines();
- await machines.ConnectAsync();
-
- machines.AliceRouter.Engage(machines.BobId);
- machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 42, InputEventFlags.None));
- await WaitForAsync(() => machines.Snapshot().Length >= 1, "the keypress to arrive");
-
- machines.AliceRouter.Disengage();
-
- await WaitForAsync(
- () => machines.Snapshot().Any(e => e.Kind == InputEventKind.ReleaseAll),
- "the release to reach the machine being left");
-
- Assert.False(machines.AliceRouter.IsEngaged);
- }
-
- [Fact]
- public async Task MouseMotionMergesOnTheWayOutButTheClickStaysBehindIt()
- {
- // Proves the coalescer is genuinely in this path rather than merely compiled into it, and
- // that the ordering rule survives the whole journey: a drag must land where it was aimed.
- await using var machines = new TwoMachines();
- await machines.ConnectAsync();
-
- machines.AliceRouter.Engage(machines.BobId);
-
- for (int i = 0; i < 50; i++)
- {
- machines.AliceRouter.Capture(InputEvent.MouseMove(Stopwatch.GetTimestamp(), 2, 1));
- }
-
- machines.AliceRouter.Capture(InputEvent.MouseButton(Stopwatch.GetTimestamp(), 0, up: false));
-
- await WaitForAsync(
- () => machines.Snapshot().Any(e => e.Kind == InputEventKind.MouseButton),
- "the click to arrive");
-
- InputEvent[] received = machines.Snapshot();
- InputEvent[] motion = [.. received.Where(e => e.Kind == InputEventKind.MouseMove)];
-
- // Every pixel of movement is still there; it simply travelled in fewer events.
- Assert.Equal(100, motion.Sum(e => e.X));
- Assert.Equal(50, motion.Sum(e => e.Y));
-
- int click = Array.FindIndex(received, e => e.Kind == InputEventKind.MouseButton);
- Assert.Equal(received.Length - 1, click);
- }
-
- [Fact]
- public async Task InputFlowsBothWaysAtOnceBecauseEitherPeerMayBeDriving()
- {
- // A can drive B while C drives A, so a link is never one-directional. Only loopback
- // suppression makes that safe, and it only matters if both directions really work.
- await using var machines = new TwoMachines();
- await machines.ConnectAsync();
-
- var backToAlice = new List();
- machines.AliceMesh.InputReceived += (_, batch, _) =>
- {
- lock (backToAlice)
- {
- foreach (InputEvent inputEvent in batch)
- {
- backToAlice.Add(inputEvent);
- }
- }
- };
-
- machines.AliceRouter.Engage(machines.BobId);
- machines.AliceRouter.Capture(InputEvent.Key(Stopwatch.GetTimestamp(), 30, InputEventFlags.None));
-
- Assert.True(machines.BobMesh.TrySend(
- machines.AliceId, InputEvent.Key(Stopwatch.GetTimestamp(), 31, InputEventFlags.None)));
-
- await WaitForAsync(() => machines.Snapshot().Length >= 1, "Alice's keystroke to reach Bob");
-
- await WaitForAsync(
- () =>
- {
- lock (backToAlice)
- {
- return backToAlice.Count >= 1;
- }
- },
- "Bob's keystroke to reach Alice");
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs
deleted file mode 100644
index 93490ef..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/MouseCalibrationTests.cs
+++ /dev/null
@@ -1,221 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.InputEngine;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class MouseCalibrationTests
-{
- private static readonly PointerBallistics Defaults = PointerBallistics.Default;
-
- private static (int X, int Y) Sum(Span events, int count)
- {
- int x = 0, y = 0;
- for (int i = 0; i < count; i++)
- {
- x += events[i].X;
- y += events[i].Y;
- }
-
- return (x, y);
- }
-
- [Fact]
- public void SpeedSliderMidpointIsUnityAndTheCurveIsNotLinear()
- {
- Assert.Equal(1.0, Defaults.SpeedMultiplier, 5);
- Assert.Equal(0.5, new PointerBallistics(6, 10, 1, 6).SpeedMultiplier, 5);
- Assert.Equal(3.5, new PointerBallistics(6, 10, 1, 20).SpeedMultiplier, 5);
-
- // Slider position is not proportional to multiplier: 20 is 3.5x, not 2x of 10.
- Assert.NotEqual(2.0, new PointerBallistics(6, 10, 1, 20).SpeedMultiplier, 5);
- }
-
- [Fact]
- public void NoChunkExceedsTheAccelerationThreshold()
- {
- // This is the whole point: a chunk above the threshold gets silently doubled by Windows.
- var calibration = new MouseCalibration(Defaults);
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 100, -73, output);
-
- Assert.True(count > 1, "a large delta must be split");
- for (int i = 0; i < count; i++)
- {
- Assert.True(Math.Abs(output[i].X) <= Defaults.Threshold1, $"chunk X {output[i].X} exceeds threshold");
- Assert.True(Math.Abs(output[i].Y) <= Defaults.Threshold1, $"chunk Y {output[i].Y} exceeds threshold");
- }
- }
-
- [Fact]
- public void ChunksSumToTheRequestedTotal()
- {
- var calibration = new MouseCalibration(Defaults);
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- foreach ((int dx, int dy) in new[] { (100, -73), (7, 0), (0, -1), (-45, 45), (1, 1) })
- {
- calibration.Reset();
- int count = calibration.Prepare(1, dx, dy, output);
- Assert.Equal((dx, dy), Sum(output, count));
- }
- }
-
- [Fact]
- public void SmallDeltasStayAsOneChunk()
- {
- var calibration = new MouseCalibration(Defaults);
- Span output = stackalloc InputEvent[16];
-
- // Under the threshold, no splitting is needed and none should happen.
- Assert.Equal(1, calibration.Prepare(1, 3, 2, output));
- }
-
- [Fact]
- public void AccelerationDisabledMeansNoChunkingAtAll()
- {
- // With acceleration off there is no doubling to avoid, so a big delta stays whole.
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, Acceleration: 0, Speed: 10));
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 500, 500, output);
-
- Assert.Equal(1, count);
- Assert.Equal((500, 500), Sum(output, count));
- }
-
- [Fact]
- public void FastPointerSpeedIsCompensatedByScalingDown()
- {
- // Speed 14 doubles motion, so half as much must be sent to travel the same distance.
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 14));
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 100, 0, output);
-
- Assert.Equal(50, Sum(output, count).X);
- }
-
- [Fact]
- public void SlowPointerSpeedIsCompensatedByScalingUp()
- {
- // Speed 6 halves motion, so twice as much must be sent.
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 6));
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 10, 0, output);
-
- Assert.Equal(20, Sum(output, count).X);
- }
-
- [Fact]
- public void SubUnitMovementAccumulatesRatherThanVanishing()
- {
- // At speed 20 a delta of 1 scales to 0.28, which truncates to zero. Without carrying the
- // remainder the pointer would never move at all during slow, precise movement -- a far
- // worse artefact than being a fraction of a pixel out.
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 20));
- Span output = stackalloc InputEvent[16];
-
- int emitted = 0;
- for (int i = 0; i < 20; i++)
- {
- int count = calibration.Prepare(i, 1, 0, output);
- emitted += Sum(output, count).X;
- }
-
- // 20 units at 1/3.5 scaling is ~5.7; the carry should have produced most of that.
- Assert.InRange(emitted, 4, 6);
- }
-
- [Fact]
- public void ResetDiscardsCarry()
- {
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, Speed: 20));
- Span output = stackalloc InputEvent[16];
-
- for (int i = 0; i < 3; i++)
- {
- calibration.Prepare(i, 1, 0, output);
- }
-
- calibration.Reset();
-
- // With the carry cleared, a single sub-unit delta must produce nothing.
- Assert.Equal(0, calibration.Prepare(9, 1, 0, output));
- }
-
- [Fact]
- public void ChunkCountIsBoundedForEnormousDeltas()
- {
- var calibration = new MouseCalibration(Defaults);
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 100_000, 0, output);
-
- Assert.InRange(count, 1, MouseCalibration.MaxChunksPerEvent);
- Assert.Equal(100_000, Sum(output, count).X);
- }
-
- [Fact]
- public void CorrectionCurveInterpolatesBetweenMeasuredSamples()
- {
- var curve = new MouseCorrectionCurve([10, 100], [1.5, 1.1]);
-
- Assert.Equal(1.5, curve.FactorFor(10), 4);
- Assert.Equal(1.1, curve.FactorFor(100), 4);
- Assert.Equal(1.3, curve.FactorFor(55), 2);
-
- // Held flat beyond the measured range rather than extrapolated into nonsense.
- Assert.Equal(1.5, curve.FactorFor(1), 4);
- Assert.Equal(1.1, curve.FactorFor(10_000), 4);
- }
-
- [Fact]
- public void CorrectionCurveIsAppliedByMagnitudeNotPerAxis()
- {
- // The acceleration curve responds to the overall movement, so a diagonal move of the same
- // magnitude as a horizontal one must be corrected the same way.
- var curve = new MouseCorrectionCurve([10, 100], [2.0, 1.0]);
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, 10)) { Correction = curve };
-
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 10, 0, output);
- Assert.Equal(20, Sum(output, count).X);
-
- // 30/40 has magnitude 50, midway on the curve, so a factor of ~1.5.
- calibration.Reset();
- count = calibration.Prepare(1, 30, 40, output);
- (int x, int y) = Sum(output, count);
- Assert.InRange(x, 43, 47);
- Assert.InRange(y, 58, 62);
- }
-
- [Fact]
- public void IdentityCurveLeavesMotionUnchanged()
- {
- var calibration = new MouseCalibration(new PointerBallistics(6, 10, 0, 10));
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
-
- int count = calibration.Prepare(1, 137, -42, output);
- Assert.Equal((137, -42), Sum(output, count));
- }
-
- [Fact]
- public void PreparationAllocatesNothing()
- {
- var calibration = new MouseCalibration(Defaults);
- Span output = stackalloc InputEvent[MouseCalibration.MaxChunksPerEvent];
- calibration.Prepare(1, 10, 10, output);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- calibration.Prepare(i, 13, -7, output);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs
deleted file mode 100644
index e1064ad..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/PairingWindowTests.cs
+++ /dev/null
@@ -1,284 +0,0 @@
-using System.Diagnostics;
-using System.Net;
-using System.Net.Security;
-using System.Net.Sockets;
-using System.Security.Authentication;
-using System.Security.Cryptography.X509Certificates;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// The enrollment exchange over real TLS, which is the only place the join key is ever used and
-/// the only moment an unenrolled machine is allowed to talk to this one.
-///
-public class PairingWindowTests : IDisposable
-{
- private const string Key = "ABCD-EFGH-JKLM-NPQR";
-
- private readonly X509Certificate2 _accepterCertificate =
- NodeCertificate.Create("CN=PairAccepter", StoreLocation.CurrentUser);
-
- private readonly X509Certificate2 _joinerCertificate =
- NodeCertificate.Create("CN=PairJoiner", StoreLocation.CurrentUser);
-
- private readonly Guid _accepterId = Guid.NewGuid();
- private readonly Guid _joinerId = Guid.NewGuid();
-
- private PairingWindow OpenWindow(TimeSpan? duration = null) =>
- new(
- new ClusterIdentity
- {
- NodeId = _accepterId,
- ClusterId = "0123456789ABCDEF",
- ClusterName = "Home",
- JoinKey = JoinKey.Normalize(Key),
- DisplayName = "Desk",
- },
- _accepterCertificate,
- duration ?? TimeSpan.FromSeconds(30),
- port: 0);
-
- private Task JoinAsync(int port, string key = Key) =>
- PairingWindow.JoinAsync(
- "127.0.0.1", port, key, _joinerId, _joinerCertificate, "Laptop");
-
- [Fact]
- public async Task TwoMachinesHoldingTheSameKeyEnrollEachOther()
- {
- await using PairingWindow window = OpenWindow();
-
- var accepted = new TaskCompletionSource();
- window.AttemptCompleted += outcome => accepted.TrySetResult(outcome);
-
- EnrollmentOutcome joinerSaw = await JoinAsync(window.Port);
- EnrollmentOutcome accepterSaw = await accepted.Task.WaitAsync(TimeSpan.FromSeconds(15));
-
- Assert.True(joinerSaw.Accepted, joinerSaw.Detail);
- Assert.True(accepterSaw.Accepted, accepterSaw.Detail);
-
- // Each side comes away with the other's identity and the exact certificate it will pin.
- // Everything after this point is authorised by that thumbprint alone.
- Assert.Equal(_accepterId, joinerSaw.PeerNodeId);
- Assert.Equal(NodeCertificate.ThumbprintOf(_accepterCertificate), joinerSaw.PeerThumbprint);
- Assert.Equal("Desk", joinerSaw.PeerDisplayName);
- Assert.Equal("Home", joinerSaw.ClusterName);
-
- Assert.Equal(_joinerId, accepterSaw.PeerNodeId);
- Assert.Equal(NodeCertificate.ThumbprintOf(_joinerCertificate), accepterSaw.PeerThumbprint);
- Assert.Equal("Laptop", accepterSaw.PeerDisplayName);
- }
-
- [Fact]
- public async Task AWrongKeyIsRefused()
- {
- await using PairingWindow window = OpenWindow();
-
- EnrollmentOutcome outcome = await JoinAsync(window.Port, "ZZZZ-ZZZZ-ZZZZ-ZZZZ");
-
- Assert.False(outcome.Accepted);
- Assert.Equal(EnrollmentResult.WrongKey, outcome.Result);
- }
-
- [Fact]
- public async Task TranscriptionDifferencesInTheTypedKeyStillJoin()
- {
- // The key is read off one screen and typed into another, so the spacing and case it
- // arrives in must not be the difference between joining and being told it is wrong.
- await using PairingWindow window = OpenWindow();
-
- EnrollmentOutcome outcome = await JoinAsync(window.Port, " abcd efgh JKLM-npqr ");
-
- Assert.True(outcome.Accepted, outcome.Detail);
- }
-
- [Fact]
- public async Task AnInterceptedConnectionIsRefusedEvenWithTheRightKey()
- {
- // The property the whole thumbprint-binding exists for. The relay knows neither private
- // key; it only re-terminates TLS, which is exactly what an attacker on the same network
- // can do. It can relay both proofs verbatim and they still do not verify, because each was
- // computed over the certificates the honest peers actually presented.
- await using PairingWindow window = OpenWindow();
- await using var relay = new InterceptingRelay(window.Port);
-
- EnrollmentOutcome outcome = await JoinAsync(relay.Port);
-
- Assert.False(outcome.Accepted);
-
- // And it is named for what it is. Reporting "wrong key" here would send the user to check
- // the key while the interceptor waits for them to try again.
- Assert.Equal(EnrollmentResult.CertificateMismatch, outcome.Result);
- Assert.Contains("not direct", outcome.Detail);
- }
-
- [Fact]
- public async Task AClosedWindowAcceptsNothingAtAll()
- {
- int port;
-
- await using (PairingWindow window = OpenWindow())
- {
- port = window.Port;
- }
-
- // Not refused by a check inside the handshake -- there is nothing listening. The join key
- // is unreachable rather than merely unused while the window is shut.
- await Assert.ThrowsAnyAsync(() => JoinAsync(port));
- }
-
- [Fact]
- public async Task TheWindowClosesItselfWhenItsTimeIsUp()
- {
- await using PairingWindow window = OpenWindow(TimeSpan.FromMilliseconds(300));
-
- Assert.True(window.IsOpen);
- await WaitForAsync(() => !window.IsOpen, "the window to close on its own");
- }
-
- [Fact]
- public async Task ARunOfAttemptsClosesTheWindowRatherThanThrottlingIt()
- {
- await using PairingWindow window = OpenWindow();
-
- // Eighty bits of key is not something a person fails at eleven times in a row. Being
- // probed is the only explanation, and the answer to it is to stop being reachable.
- for (int attempt = 0; attempt < 12 && window.IsOpen; attempt++)
- {
- try
- {
- using var probe = new TcpClient();
- await probe.ConnectAsync("127.0.0.1", window.Port);
- }
- catch (SocketException)
- {
- break;
- }
-
- await Task.Delay(20);
- }
-
- await WaitForAsync(() => !window.IsOpen, "the window to close after repeated attempts");
- }
-
- private static async Task WaitForAsync(Func condition, string what)
- {
- var deadline = Stopwatch.StartNew();
- while (deadline.Elapsed < TimeSpan.FromSeconds(15))
- {
- if (condition())
- {
- return;
- }
-
- await Task.Delay(10);
- }
-
- Assert.Fail("timed out waiting for " + what);
- }
-
- ///
- /// A machine-in-the-middle: terminates TLS on both sides with its own certificate and copies
- /// the plaintext between them without altering it.
- ///
- private sealed class InterceptingRelay : IAsyncDisposable
- {
- private readonly X509Certificate2 _certificate =
- NodeCertificate.Create("CN=Interceptor", StoreLocation.CurrentUser);
-
- private readonly TcpListener _listener;
- private readonly CancellationTokenSource _stopping = new();
- private readonly Task _loop;
- private readonly int _targetPort;
-
- public InterceptingRelay(int targetPort)
- {
- _targetPort = targetPort;
- _listener = new TcpListener(IPAddress.Loopback, 0);
- _listener.Start();
- Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
- _loop = Task.Run(() => RunAsync(_stopping.Token));
- }
-
- public int Port { get; }
-
- private async Task RunAsync(CancellationToken cancellationToken)
- {
- try
- {
- using TcpClient victim = await _listener.AcceptTcpClientAsync(cancellationToken);
- using var upstream = new TcpClient();
- await upstream.ConnectAsync("127.0.0.1", _targetPort, cancellationToken);
-
- await using var toVictim = new SslStream(
- victim.GetStream(), false, (_, _, _, _) => true);
-
- await using var toAccepter = new SslStream(
- upstream.GetStream(), false, (_, _, _, _) => true);
-
- Task asServer = toVictim.AuthenticateAsServerAsync(
- new SslServerAuthenticationOptions
- {
- ServerCertificate = _certificate,
- ClientCertificateRequired = true,
- EnabledSslProtocols = SslProtocols.Tls13,
- CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
- },
- cancellationToken);
-
- Task asClient = toAccepter.AuthenticateAsClientAsync(
- new SslClientAuthenticationOptions
- {
- TargetHost = "VirtualKmSwitch",
- ClientCertificates = [_certificate],
- EnabledSslProtocols = SslProtocols.Tls13,
- CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
- },
- cancellationToken);
-
- await Task.WhenAll(asServer, asClient);
-
- await Task.WhenAny(
- Pump(toVictim, toAccepter, cancellationToken),
- Pump(toAccepter, toVictim, cancellationToken));
- }
- catch (Exception)
- {
- // The victim aborting mid-exchange is the expected end of this, not a fault.
- }
- }
-
- private static async Task Pump(SslStream from, SslStream to, CancellationToken cancellationToken)
- {
- byte[] buffer = new byte[8192];
-
- while (!cancellationToken.IsCancellationRequested)
- {
- int read = await from.ReadAsync(buffer, cancellationToken);
- if (read == 0)
- {
- return;
- }
-
- await to.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- await _stopping.CancelAsync();
- _listener.Stop();
- await Task.WhenAny(_loop, Task.Delay(TimeSpan.FromSeconds(2)));
- _stopping.Dispose();
- _certificate.Dispose();
- }
- }
-
- public void Dispose()
- {
- GC.SuppressFinalize(this);
- _accepterCertificate.Dispose();
- _joinerCertificate.Dispose();
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs
deleted file mode 100644
index 75d7c17..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerConnectionTests.cs
+++ /dev/null
@@ -1,380 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Security.Cryptography.X509Certificates;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// Two peers over a real loopback socket, with real mutual TLS and real pinning. Only the network
-/// is local; the handshake, the certificate validation and the framing are all genuine.
-///
-public class PeerConnectionTests
-{
- private sealed class Fixture : IAsyncDisposable
- {
- public X509Certificate2 AliceCertificate { get; } =
- NodeCertificate.Create("CN=Alice", StoreLocation.CurrentUser);
-
- public X509Certificate2 BobCertificate { get; } =
- NodeCertificate.Create("CN=Bob", StoreLocation.CurrentUser);
-
- public Guid AliceId { get; } = Guid.NewGuid();
-
- public Guid BobId { get; } = Guid.NewGuid();
-
- public PinStore AlicePins { get; } = new();
-
- public PinStore BobPins { get; } = new();
-
- /// Pins each side to the other, as a completed enrollment would.
- public void PinEachOther()
- {
- AlicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId);
- BobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId);
- }
-
- public ValueTask DisposeAsync()
- {
- AliceCertificate.Dispose();
- BobCertificate.Dispose();
- return ValueTask.CompletedTask;
- }
- }
-
- /// Establishes a connected, mutually authenticated pair on an ephemeral port.
- private static async Task<(PeerConnection Client, PeerConnection Server)> ConnectPairAsync(Fixture fixture)
- {
- var listener = new TcpListener(IPAddress.Loopback, 0);
- listener.Start();
-
- try
- {
- int port = ((IPEndPoint)listener.LocalEndpoint).Port;
-
- Task accepting = listener.AcceptTcpClientAsync();
-
- Task clientTask = PeerConnection.ConnectAsync(
- IPAddress.Loopback.ToString(), port, fixture.AliceId, fixture.AliceCertificate, fixture.AlicePins);
-
- TcpClient accepted = await accepting;
- Task serverTask = PeerConnection.AcceptAsync(
- accepted, fixture.BobId, fixture.BobCertificate, fixture.BobPins);
-
- return (await clientTask, await serverTask);
- }
- finally
- {
- listener.Stop();
- }
- }
-
- [Fact]
- public async Task MutuallyPinnedPeersConnectAndIdentifyEachOther()
- {
- await using var fixture = new Fixture();
- fixture.PinEachOther();
-
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
-
- await using (client)
- await using (server)
- {
- // Each side resolved the other's identity from its pinned certificate, not from
- // anything the peer claimed about itself.
- Assert.Equal(fixture.BobId, client.PeerId);
- Assert.Equal(fixture.AliceId, server.PeerId);
- }
- }
-
- [Fact]
- public async Task AnUnpinnedPeerIsRefused()
- {
- // The whole authorisation model: without a pin there is no CA to fall back on, so the
- // handshake must fail rather than accept a valid-looking self-signed certificate.
- await using var fixture = new Fixture();
- fixture.BobPins.Pin(NodeCertificate.ThumbprintOf(fixture.AliceCertificate), fixture.AliceId);
-
- // Alice does not pin Bob, so Alice must reject Bob's certificate.
- await Assert.ThrowsAnyAsync(async () =>
- {
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
- await client.DisposeAsync();
- await server.DisposeAsync();
- });
- }
-
- [Fact]
- public async Task APeerWithNoCertificatePinnedAtAllIsRefused()
- {
- await using var fixture = new Fixture();
-
- await Assert.ThrowsAnyAsync(async () =>
- {
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
- await client.DisposeAsync();
- await server.DisposeAsync();
- });
- }
-
- [Fact]
- public async Task InputEventsTravelAcrossTheEncryptedLink()
- {
- await using var fixture = new Fixture();
- fixture.PinEachOther();
-
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
-
- await using (client)
- await using (server)
- {
- var received = new List();
- var done = new TaskCompletionSource();
- using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- Task receiving = server.ReceiveLoopAsync(
- (_, events, _) =>
- {
- foreach (ref readonly InputEvent inputEvent in events)
- {
- received.Add(inputEvent);
- }
-
- if (received.Count >= 3)
- {
- done.TrySetResult();
- }
- },
- onControl: null,
- cancellation.Token);
-
- InputEvent[] batch =
- [
- InputEvent.MouseMove(100, 5, -5),
- InputEvent.Key(100, 0x1E, InputEventFlags.None),
- InputEvent.MouseButton(100, 0, up: true),
- ];
-
- // Measured as a delta: the connection has already exchanged a hello frame, which is
- // what proves the peer accepted it.
- long framesBefore = client.FramesSent;
-
- await client.SendInputAsync(100, batch);
- await done.Task.WaitAsync(TimeSpan.FromSeconds(10));
-
- Assert.Equal(3, received.Count);
- Assert.Equal(InputEventKind.MouseMove, received[0].Kind);
- Assert.Equal(5, received[0].X);
- Assert.Equal(0x1E, received[1].Code);
- Assert.True(received[2].IsUp);
-
- Assert.Equal(1, client.FramesSent - framesBefore);
- Assert.Equal(3, client.EventsSent);
-
- await cancellation.CancelAsync();
- await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2)));
- }
- }
-
- [Fact]
- public async Task ManyBatchesArriveCompleteAndInOrder()
- {
- // Frames split and coalesce arbitrarily across TCP reads; ordering is what a key-up
- // depends on, so this is the property that matters most.
- await using var fixture = new Fixture();
- fixture.PinEachOther();
-
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
-
- await using (client)
- await using (server)
- {
- const int batches = 500;
- const int perBatch = 8;
-
- var received = new List(batches * perBatch);
- var done = new TaskCompletionSource();
- using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(20));
-
- Task receiving = server.ReceiveLoopAsync(
- (_, events, _) =>
- {
- foreach (ref readonly InputEvent inputEvent in events)
- {
- received.Add(inputEvent.X);
- }
-
- if (received.Count >= batches * perBatch)
- {
- done.TrySetResult();
- }
- },
- onControl: null,
- cancellation.Token);
-
- var buffer = new InputEvent[perBatch];
- int sequence = 0;
-
- for (int batch = 0; batch < batches; batch++)
- {
- for (int i = 0; i < perBatch; i++)
- {
- buffer[i] = InputEvent.MouseMove(batch, sequence++, 0);
- }
-
- await client.SendInputAsync(batch, buffer);
- }
-
- await done.Task.WaitAsync(TimeSpan.FromSeconds(20));
-
- Assert.Equal(batches * perBatch, received.Count);
- for (int i = 0; i < received.Count; i++)
- {
- Assert.Equal(i, received[i]);
- }
-
- await cancellation.CancelAsync();
- await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2)));
- }
- }
-
- [Fact]
- public async Task ControlPayloadsTravelAlongsideInput()
- {
- await using var fixture = new Fixture();
- fixture.PinEachOther();
-
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
-
- await using (client)
- await using (server)
- {
- byte[]? seen = null;
- var done = new TaskCompletionSource();
- using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- Task receiving = server.ReceiveLoopAsync(
- onInput: null,
- onControl: (_, payload) =>
- {
- seen = payload.ToArray();
- done.TrySetResult();
- },
- cancellation.Token);
-
- byte[] message = "hello"u8.ToArray();
- await client.SendControlAsync(message);
- await done.Task.WaitAsync(TimeSpan.FromSeconds(10));
-
- Assert.Equal(message, seen);
-
- await cancellation.CancelAsync();
- await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2)));
- }
- }
-
- [Fact]
- public async Task LatencyIsMeasuredOnReceive()
- {
- await using var fixture = new Fixture();
- fixture.PinEachOther();
-
- (PeerConnection client, PeerConnection server) = await ConnectPairAsync(fixture);
-
- await using (client)
- await using (server)
- {
- var done = new TaskCompletionSource();
- using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- Task receiving = server.ReceiveLoopAsync(
- (_, _, _) => done.TrySetResult(), onControl: null, cancellation.Token);
-
- // Microseconds, which is what the wire carries. Handing it Stopwatch ticks is exactly
- // the mistake the shared clock exists to prevent, and it reads as a plausible number.
- await client.SendInputAsync(
- MonotonicClock.Microseconds, new[] { InputEvent.MouseMove(0, 1, 1) });
-
- await done.Task.WaitAsync(TimeSpan.FromSeconds(10));
-
- Assert.Equal(1, server.Latency.Count);
- Assert.True(server.Latency.MaxMicroseconds >= 0);
-
- await cancellation.CancelAsync();
- await Task.WhenAny(receiving, Task.Delay(TimeSpan.FromSeconds(2)));
- }
- }
-}
-
-public class LatencyTrackerTests
-{
- [Fact]
- public void PercentilesReflectTheDistribution()
- {
- var tracker = new LatencyTracker();
-
- // 99 fast samples and one very slow one: the mean would hide the outlier entirely, which
- // is exactly why the budget is stated as a p99.
- for (int i = 0; i < 99; i++)
- {
- tracker.Record(100);
- }
-
- tracker.Record(50_000);
-
- Assert.Equal(100, tracker.Count);
- Assert.True(tracker.Percentile(0.50) <= 200);
- Assert.True(tracker.Percentile(0.99) <= 200);
- Assert.True(tracker.Percentile(1.0) >= 50_000);
- Assert.Equal(50_000, tracker.MaxMicroseconds);
- }
-
- [Fact]
- public void NegativeSamplesAreDiscarded()
- {
- // A negative measurement means the clock offset estimate is stale; counting it would drag
- // the percentiles below anything achievable.
- var tracker = new LatencyTracker();
- tracker.Record(-500);
-
- Assert.Equal(0, tracker.Count);
- }
-
- [Fact]
- public void ResolutionIsFinestWhereTheBudgetLives()
- {
- var tracker = new LatencyTracker();
- tracker.Record(45);
-
- // Sub-millisecond samples are bucketed to 10 microseconds.
- Assert.InRange(tracker.Percentile(0.5), 45, 55);
- }
-
- [Fact]
- public void RecordingAllocatesNothing()
- {
- var tracker = new LatencyTracker();
- tracker.Record(100);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 10_000; i++)
- {
- tracker.Record(i % 5_000);
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-
- [Fact]
- public void EmptyTrackerReportsZeroRatherThanFailing()
- {
- var tracker = new LatencyTracker();
-
- Assert.Equal(0, tracker.Count);
- Assert.Equal(0, tracker.Percentile(0.99));
- Assert.Equal(0, tracker.MeanMicroseconds);
- Assert.Equal("no samples", tracker.Summary());
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs
deleted file mode 100644
index 94d3559..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerMeshTests.cs
+++ /dev/null
@@ -1,305 +0,0 @@
-using System.Diagnostics;
-using System.Security.Cryptography.X509Certificates;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// Two complete meshes on loopback: real listeners, real dialling, real mutual TLS, real pinning.
-///
-public class PeerMeshTests
-{
- /// Ephemeral ports per test so parallel runs cannot collide.
- private static int NextPort() => Random.Shared.Next(45_000, 60_000);
-
- private sealed class TwoNodes : IAsyncDisposable
- {
- public TwoNodes()
- {
- AliceCertificate = NodeCertificate.Create("CN=MeshAlice", StoreLocation.CurrentUser);
- BobCertificate = NodeCertificate.Create("CN=MeshBob", StoreLocation.CurrentUser);
-
- // Ordered so Alice is always the dialler, matching the lower-id-dials rule.
- Guid first = Guid.NewGuid();
- Guid second = Guid.NewGuid();
- (AliceId, BobId) = first.CompareTo(second) < 0 ? (first, second) : (second, first);
-
- AlicePins.Pin(NodeCertificate.ThumbprintOf(BobCertificate), BobId);
- BobPins.Pin(NodeCertificate.ThumbprintOf(AliceCertificate), AliceId);
-
- AlicePort = NextPort();
- BobPort = AlicePort + 1;
-
- Alice = new PeerMesh(AliceId, AliceCertificate, AlicePins, AlicePort);
- Bob = new PeerMesh(BobId, BobCertificate, BobPins, BobPort);
- }
-
- public X509Certificate2 AliceCertificate { get; }
-
- public X509Certificate2 BobCertificate { get; }
-
- public Guid AliceId { get; }
-
- public Guid BobId { get; }
-
- public PinStore AlicePins { get; } = new();
-
- public PinStore BobPins { get; } = new();
-
- public int AlicePort { get; }
-
- public int BobPort { get; }
-
- public PeerMesh Alice { get; }
-
- public PeerMesh Bob { get; }
-
- public async Task ConnectAsync()
- {
- Alice.Start();
- Bob.Start();
-
- return await Alice.EnsureConnectedAsync(BobId, "127.0.0.1", BobPort);
- }
-
- public async ValueTask DisposeAsync()
- {
- await Alice.DisposeAsync();
- await Bob.DisposeAsync();
- AliceCertificate.Dispose();
- BobCertificate.Dispose();
- }
- }
-
- private static async Task WaitForAsync(Func condition, TimeSpan timeout)
- {
- var deadline = Stopwatch.StartNew();
- while (deadline.Elapsed < timeout)
- {
- if (condition())
- {
- return;
- }
-
- await Task.Delay(20);
- }
- }
-
- [Fact]
- public void DiallingIsDecidedByNodeOrderSoBothSidesAgree()
- {
- // Both peers see each other simultaneously. Without a total order they would dial at once
- // and build two links where one is wanted; each side evaluates this identically.
- var low = new Guid("00000000-0000-0000-0000-000000000001");
- var high = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff");
-
- using X509Certificate2 certificate = NodeCertificate.Create("CN=Order", StoreLocation.CurrentUser);
-
- var lowMesh = new PeerMesh(low, certificate, new PinStore());
- var highMesh = new PeerMesh(high, certificate, new PinStore());
-
- Assert.True(lowMesh.ShouldDial(high));
- Assert.False(highMesh.ShouldDial(low));
- }
-
- [Fact]
- public async Task PeersConnectAndBothSidesSeeTheLink()
- {
- await using var nodes = new TwoNodes();
-
- Assert.True(await nodes.ConnectAsync());
- await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10));
-
- Assert.Contains(nodes.BobId, nodes.Alice.ConnectedPeers);
- Assert.Contains(nodes.AliceId, nodes.Bob.ConnectedPeers);
- }
-
- [Fact]
- public async Task InputFlowsBetweenMeshes()
- {
- await using var nodes = new TwoNodes();
- Assert.True(await nodes.ConnectAsync());
-
- var received = new List();
- var done = new TaskCompletionSource();
-
- nodes.Bob.InputReceived += (_, events, _) =>
- {
- foreach (ref readonly InputEvent inputEvent in events)
- {
- received.Add(inputEvent);
- }
-
- done.TrySetResult();
- };
-
- await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10));
-
- await nodes.Alice.SendInputAsync(
- nodes.BobId, 42, new[] { InputEvent.Key(42, 0x1E, InputEventFlags.None) });
-
- await done.Task.WaitAsync(TimeSpan.FromSeconds(10));
-
- Assert.Single(received);
- Assert.Equal(0x1E, received[0].Code);
- }
-
- [Fact]
- public async Task SendingToAnUnknownPeerIsIgnoredRatherThanThrowing()
- {
- // Peers disappear constantly — sleep, reboot, network change. A send racing a disconnect
- // is normal traffic, not an error worth propagating into the input path.
- await using var nodes = new TwoNodes();
- nodes.Alice.Start();
-
- await nodes.Alice.SendInputAsync(Guid.NewGuid(), 1, new[] { InputEvent.MouseMove(1, 1, 1) });
- }
-
- [Fact]
- public async Task AnUnpinnedPeerCannotJoinTheMesh()
- {
- await using var nodes = new TwoNodes();
-
- // Bob forgets Alice, as removing her from the mesh would do.
- nodes.BobPins.Clear();
-
- nodes.Alice.Start();
- nodes.Bob.Start();
-
- Assert.False(await nodes.Alice.EnsureConnectedAsync(nodes.BobId, "127.0.0.1", nodes.BobPort));
- Assert.Empty(nodes.Alice.ConnectedPeers);
- }
-
- [Fact]
- public async Task AnEightKilohertzStreamStaysInsideTheLatencyBudgetAndKeepsItsOrder()
- {
- // The plan budgets p99 added latency at 2 ms and calls for a synthetic 8 kHz load through
- // the full pipeline. On loopback the wire costs nothing, so what is measured is the queue,
- // the coalescer, the codec and TLS -- and it should sit far below the budget, leaving the
- // rest of it for a real network.
- //
- // Pacing matters as much as the count. An unpaced loop produces events faster than any
- // mouse in existence, the queue backs up, and the resulting figure measures the test
- // harness rather than the transport. Measured that way the same code reports 6 ms.
- await using var nodes = new TwoNodes();
- Assert.True(await nodes.ConnectAsync());
- await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10));
-
- const int events = 2_000;
- const long periodMicroseconds = 125; // 8 kHz
-
- var received = new List(events);
- var done = new TaskCompletionSource();
-
- // Measured a second way, straight from the batch timestamp, with no clock offset applied.
- // On loopback both ends read the same clock so the two must agree, and holding them to
- // that caught a real bug: a cold first heartbeat biased every reported figure by 2.3 ms
- // while this direct measurement stayed at 98 us.
- var direct = new List(events);
-
- nodes.Bob.InputReceived += (_, batch, capture) =>
- {
- direct.Add(MonotonicClock.Microseconds - capture);
-
- foreach (InputEvent inputEvent in batch)
- {
- received.Add(inputEvent.Code);
- }
-
- if (received.Count >= events)
- {
- done.TrySetResult();
- }
- };
-
- // Key events, not motion: motion is deliberately allowed to merge, so a test that counted
- // it would be asserting on how the scheduler happened to interleave.
- long next = MonotonicClock.Microseconds;
- for (int i = 0; i < events; i++)
- {
- while (MonotonicClock.Microseconds < next)
- {
- Thread.SpinWait(10);
- }
-
- next += periodMicroseconds;
- Assert.True(nodes.Alice.TrySend(
- nodes.BobId, InputEvent.Key(Stopwatch.GetTimestamp(), (ushort)i, InputEventFlags.None)));
- }
-
- await done.Task.WaitAsync(TimeSpan.FromSeconds(30));
-
- PeerConnection connection = nodes.Bob.Connection(nodes.AliceId)!;
- PeerSendPump pump = nodes.Alice.Pump(nodes.BobId)!;
- long p99 = connection.Latency.Percentile(0.99);
-
- Assert.Equal(events, received.Count);
-
- // Nothing reordered. A key-up that overtook its key-down would leave the key held, which
- // is the failure the whole ordered-stream design exists to prevent.
- Assert.Equal(Enumerable.Range(0, events).Select(i => (ushort)i), received);
-
- Assert.Equal(0, pump.MovesDropped);
- Assert.True(
- p99 < 2_000,
- $"p99 was {p99}us, over the 2ms budget. Summary: {connection.Latency.Summary()}");
-
- // The two measurements must not disagree by more than the histogram's own bucket width
- // plus the work between them. A gap far larger than that means the clock estimate is
- // biased, and the reported figures are then confidently wrong rather than merely noisy.
- long directMedian = direct.Order().ElementAt(direct.Count / 2);
- Assert.InRange(connection.Latency.Percentile(0.50), 0, directMedian + 500);
-
- Assert.True(
- connection.Clock.HasSample,
- "the link reported latency without ever having synchronised its clock");
- }
-
- [Fact]
- public async Task ClockSynchronisationConvergesWithoutBeingAskedTo()
- {
- await using var nodes = new TwoNodes();
- Assert.True(await nodes.ConnectAsync());
- await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10));
-
- PeerConnection fromAlice = nodes.Bob.Connection(nodes.AliceId)!;
- PeerConnection fromBob = nodes.Alice.Connection(nodes.BobId)!;
-
- await WaitForAsync(
- () => fromAlice.Clock.HasSample && fromBob.Clock.HasSample, TimeSpan.FromSeconds(10));
-
- // Both ends of a loopback link read the same clock, so the true offset is zero. Anything
- // else here would be the estimator inventing a difference, and on two real machines that
- // invention is indistinguishable from latency.
- Assert.InRange(fromAlice.Clock.OffsetMicroseconds, -2_000, 2_000);
- Assert.InRange(fromBob.Clock.OffsetMicroseconds, -2_000, 2_000);
-
- // A round trip on loopback is fast, but the figure only has to be plausible: it is a
- // quality signal for choosing between samples, not a measurement anything reports.
- Assert.InRange(fromAlice.Clock.RoundTripMicroseconds, 0, 100_000);
- }
-
- [Fact]
- public async Task DisconnectionIsReportedToBothSides()
- {
- await using var nodes = new TwoNodes();
- Assert.True(await nodes.ConnectAsync());
- await WaitForAsync(() => nodes.Bob.ConnectionCount > 0, TimeSpan.FromSeconds(10));
-
- var bobSawDisconnect = new TaskCompletionSource();
- nodes.Bob.PeerConnectionChanged += (_, connected) =>
- {
- if (!connected)
- {
- bobSawDisconnect.TrySetResult();
- }
- };
-
- await nodes.Alice.DisposeAsync();
- await bobSawDisconnect.Task.WaitAsync(TimeSpan.FromSeconds(10));
-
- Assert.Empty(nodes.Bob.ConnectedPeers);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs
deleted file mode 100644
index e6ed4ef..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/PeerSendPumpTests.cs
+++ /dev/null
@@ -1,274 +0,0 @@
-using System.Diagnostics;
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Transport;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// The outbound queue, its batching, and the one policy question that matters: what is allowed to
-/// be lost when the link cannot keep up.
-///
-public class PeerSendPumpTests
-{
- /// A sink that records what it was handed, and can be made slow on demand.
- private sealed class RecordingSink : IInputSink
- {
- private readonly Lock _gate = new();
- private readonly List _batches = [];
-
- /// Held closed to let the queue build up behind a send in progress.
- public ManualResetEventSlim Release { get; } = new(initialState: true);
-
- public bool Fail { get; set; }
-
- public int BatchCount
- {
- get
- {
- lock (_gate)
- {
- return _batches.Count;
- }
- }
- }
-
- public IReadOnlyList Batches
- {
- get
- {
- lock (_gate)
- {
- return _batches.ToArray();
- }
- }
- }
-
- public InputEvent[] AllEvents
- {
- get
- {
- lock (_gate)
- {
- return _batches.SelectMany(batch => batch).ToArray();
- }
- }
- }
-
- public bool TrySendInput(long captureMicroseconds, ReadOnlySpan events)
- {
- Release.Wait();
-
- if (Fail)
- {
- return false;
- }
-
- InputEvent[] copy = events.ToArray();
- lock (_gate)
- {
- _batches.Add(copy);
- }
-
- return true;
- }
- }
-
- private static async Task WaitForAsync(Func condition, string what)
- {
- var deadline = Stopwatch.StartNew();
- while (deadline.Elapsed < TimeSpan.FromSeconds(10))
- {
- if (condition())
- {
- return;
- }
-
- await Task.Delay(5);
- }
-
- Assert.Fail("timed out waiting for " + what);
- }
-
- [Fact]
- public async Task AtIdleAnEventGoesOutOnItsOwnRatherThanWaitingForCompany()
- {
- // The property that separates coalescing from buffering. A pump that waited for a second
- // event before sending would post better throughput numbers and feel worse to use.
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "idle");
- pump.Start();
-
- Assert.True(pump.TryEnqueue(InputEvent.Key(1, 30, InputEventFlags.None)));
-
- await WaitForAsync(() => sink.BatchCount == 1, "the lone event to be sent");
- Assert.Single(sink.Batches[0]);
- }
-
- [Fact]
- public async Task BatchesEmergeFromTheDrainWhenTheLinkIsBusy()
- {
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "busy");
-
- // Hold the sink shut so the pump blocks inside its first send while events pile up. This
- // is what a slow write looks like from the queue's side.
- sink.Release.Reset();
- pump.Start();
-
- pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None));
- await Task.Delay(30);
-
- for (int i = 0; i < 200; i++)
- {
- Assert.True(pump.TryEnqueue(InputEvent.Key(i + 2, (ushort)(i % 60), InputEventFlags.None)));
- }
-
- sink.Release.Set();
-
- await WaitForAsync(() => sink.AllEvents.Length == 201, "every event to arrive");
-
- // Nothing was dropped, and the events that queued during the stall left in far fewer
- // frames than they arrived in -- one syscall and one TLS record instead of 200.
- Assert.Equal(201, sink.AllEvents.Length);
- Assert.True(pump.LargestBatch > 1, $"largest batch was {pump.LargestBatch}; no batching happened");
- Assert.True(
- sink.BatchCount < 201,
- $"{sink.BatchCount} frames for 201 events means each one went out alone");
- }
-
- [Fact]
- public async Task MotionMergesButAClickNeverCrossesIt()
- {
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "merge");
-
- sink.Release.Reset();
- pump.Start();
-
- // Prime the pump so it is blocked in a send, then queue a gesture behind it.
- pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None));
- await Task.Delay(30);
-
- pump.TryEnqueue(InputEvent.MouseMove(10, 3, 0));
- pump.TryEnqueue(InputEvent.MouseMove(11, 4, 0));
- pump.TryEnqueue(InputEvent.MouseButton(12, 0, up: false));
- pump.TryEnqueue(InputEvent.MouseMove(13, 5, 0));
- pump.TryEnqueue(InputEvent.MouseButton(14, 0, up: true));
-
- sink.Release.Set();
-
- await WaitForAsync(
- () => sink.AllEvents.Count(e => e.Kind == InputEventKind.MouseButton) == 2,
- "both button transitions");
-
- InputEvent[] gesture = sink.AllEvents.Where(e => e.Kind != InputEventKind.Key).ToArray();
-
- // Motion before the press is one event carrying the sum, and the press still lands after
- // it. A drag that reordered here would click somewhere the user never pointed.
- Assert.Equal(InputEventKind.MouseMove, gesture[0].Kind);
- Assert.Equal(7, gesture[0].X);
- Assert.Equal(InputEventKind.MouseButton, gesture[1].Kind);
- Assert.False(gesture[1].IsUp);
- Assert.Equal(InputEventKind.MouseMove, gesture[2].Kind);
- Assert.Equal(5, gesture[2].X);
- Assert.Equal(InputEventKind.MouseButton, gesture[3].Kind);
- Assert.True(gesture[3].IsUp);
- }
-
- [Fact]
- public void AFullQueueDropsMotionAndSaysItSucceeded()
- {
- // The pump is never started, so nothing drains and the queue fills.
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "full-motion", capacity: 8);
-
- for (int i = 0; i < 8; i++)
- {
- Assert.True(pump.TryEnqueue(InputEvent.MouseMove(i, 1, 0)));
- }
-
- // Accepted, because the motion after it supersedes it: the cursor still ends up in the
- // right place, and a hitch in movement is a far cheaper failure than a stuck modifier.
- Assert.True(pump.TryEnqueue(InputEvent.MouseMove(9, 1, 0)));
- Assert.Equal(1, pump.MovesDropped);
- }
-
- [Fact]
- public void AFullQueueRefusesAKeyEventAndReportsTheStall()
- {
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "full-keys", capacity: 8);
-
- SendPumpStall? stall = null;
- pump.Stalled += reason => stall = reason;
-
- for (int i = 0; i < 8; i++)
- {
- Assert.True(pump.TryEnqueue(InputEvent.Key(i, (ushort)i, InputEventFlags.None)));
- }
-
- // Refused rather than dropped. Losing one of these is a coin flip between a key that never
- // arrives and a key-up that never arrives, and the second leaves a modifier held down on a
- // machine the user may already have stopped driving.
- Assert.False(pump.TryEnqueue(InputEvent.Key(9, 9, InputEventFlags.None)));
- Assert.Equal(0, pump.MovesDropped);
- Assert.Equal(SendPumpStall.QueueFull, stall);
- }
-
- [Fact]
- public void AReleaseAllIsNeverTreatedAsDroppable()
- {
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "release-all", capacity: 8);
-
- for (int i = 0; i < 8; i++)
- {
- pump.TryEnqueue(InputEvent.MouseMove(i, 1, 0));
- }
-
- // The event whose entire purpose is to clean up after the others.
- Assert.False(pump.TryEnqueue(InputEvent.ReleaseAll(9)));
- Assert.Equal(0, pump.MovesDropped);
- }
-
- [Fact]
- public async Task ALinkThatDiesIsReportedRatherThanRetriedForever()
- {
- var sink = new RecordingSink { Fail = true };
- using var pump = new PeerSendPump(sink, "dead");
-
- SendPumpStall? stall = null;
- pump.Stalled += reason => stall = reason;
- pump.Start();
-
- pump.TryEnqueue(InputEvent.Key(1, 1, InputEventFlags.None));
-
- await WaitForAsync(() => stall is not null, "the link loss to be reported");
- Assert.Equal(SendPumpStall.LinkLost, stall);
- }
-
- [Fact]
- public void QueueingAnEventAllocatesNothing()
- {
- // The assertion that keeps the hot path honest as the code grows. A single allocation here
- // is a gen0 collection every few seconds of typing, and a gen0 collection near the hook
- // thread is how Windows silently unhooks us.
- var sink = new RecordingSink();
- using var pump = new PeerSendPump(sink, "alloc", capacity: 1024);
-
- var warmup = InputEvent.MouseMove(1, 1, 1);
- pump.TryEnqueue(in warmup);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
-
- for (int i = 0; i < 1000; i++)
- {
- var moved = InputEvent.MouseMove(i, i, i);
- pump.TryEnqueue(in moved);
- }
-
- long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
- Assert.Equal(0, allocated);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs
deleted file mode 100644
index 2af9943..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/RosterPinProjectionTests.cs
+++ /dev/null
@@ -1,180 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// Trust is a projection of the roster, and these are the properties that makes it worth having.
-///
-/// The pins decide who may open a connection and inject keystrokes into this machine, so the
-/// question is never only "does the right peer connect" but "does the wrong one stop connecting,
-/// and stay stopped across a restart".
-///
-public class RosterPinProjectionTests : IDisposable
-{
- private const string LocalThumbprint = "1111111111111111111111111111111111111111111111111111111111111111";
-
- private readonly string _directory =
- Path.Combine(Path.GetTempPath(), "VirtualKmSwitchTests", Guid.NewGuid().ToString("N"));
-
- private ClusterMembership Create() =>
- new(new ClusterStore(Path.Combine(_directory, "roster.json")),
- new LocalSettingsStore(Path.Combine(_directory, "local.json")));
-
- private ClusterMembership CreateWithCluster()
- {
- ClusterMembership membership = Create();
- membership.CreateCluster("Home", "0123456789ABCDEF", "AAAA-BBBB-CCCC-DDDD", LocalThumbprint, "Desk");
- return membership;
- }
-
- /// Adds a peer the way a completed enrollment eventually will.
- private static Guid EnrollPeer(ClusterMembership membership, string thumbprint, string name = "Laptop")
- {
- var peerId = Guid.NewGuid();
- var theirRoster = new Roster(peerId);
- theirRoster.Enroll(peerId, thumbprint, name);
- membership.Merge(theirRoster.Snapshot());
- return peerId;
- }
-
- [Fact]
- public void EnrollingAPeerTrustsItsCertificate()
- {
- ClusterMembership membership = CreateWithCluster();
- const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222";
-
- Guid peerId = EnrollPeer(membership, peerThumbprint);
-
- Assert.True(membership.Pins.TryResolve(peerThumbprint, out Guid resolved));
- Assert.Equal(peerId, resolved);
- }
-
- [Fact]
- public void ThisMachinesOwnCertificateIsNotTrusted()
- {
- // Nothing dials itself, so pinning our own certificate would only mean that anything
- // holding a copy of our private key could present it back to us and be believed.
- ClusterMembership membership = CreateWithCluster();
-
- Assert.False(membership.Pins.IsTrusted(LocalThumbprint));
- }
-
- [Fact]
- public void AnUnknownCertificateIsNeverTrusted()
- {
- ClusterMembership membership = CreateWithCluster();
- EnrollPeer(membership, "2222222222222222222222222222222222222222222222222222222222222222");
-
- Assert.False(membership.Pins.IsTrusted(
- "3333333333333333333333333333333333333333333333333333333333333333"));
- }
-
- [Fact]
- public void RemovingAPeerRevokesItsCertificate()
- {
- ClusterMembership membership = CreateWithCluster();
- const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222";
- Guid peerId = EnrollPeer(membership, peerThumbprint);
-
- Assert.True(membership.RemoveMachine(peerId));
-
- // The point of the whole arrangement. A removal that left the pin behind would take a
- // machine off the list while it carried on being allowed to type into this one.
- Assert.False(membership.Pins.IsTrusted(peerThumbprint));
- }
-
- [Fact]
- public void LeavingTheClusterTrustsNobody()
- {
- ClusterMembership membership = CreateWithCluster();
- EnrollPeer(membership, "2222222222222222222222222222222222222222222222222222222222222222");
-
- membership.LeaveCluster();
-
- Assert.Equal(0, membership.Pins.Count);
- }
-
- [Fact]
- public void TrustSurvivesARestartWithoutAFileOfItsOwn()
- {
- const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222";
-
- ClusterMembership first = CreateWithCluster();
- Guid peerId = EnrollPeer(first, peerThumbprint);
-
- // A new instance over the same directory: what the service does after a reboot. The pins
- // are rebuilt from the roster that was already being written atomically, so there is no
- // second file to have been left half-written.
- ClusterMembership restarted = Create();
-
- Assert.True(restarted.Pins.TryResolve(peerThumbprint, out Guid resolved));
- Assert.Equal(peerId, resolved);
- }
-
- [Fact]
- public void ARemovalStaysRemovedAcrossARestart()
- {
- const string peerThumbprint = "2222222222222222222222222222222222222222222222222222222222222222";
-
- ClusterMembership first = CreateWithCluster();
- Guid peerId = EnrollPeer(first, peerThumbprint);
- first.RemoveMachine(peerId);
-
- // The tombstone is what carries this, so the revocation is not something a restart can
- // quietly undo.
- Assert.False(Create().Pins.IsTrusted(peerThumbprint));
- }
-
- [Fact]
- public void APeerThatReEnrolsWithANewCertificateLosesTheOldOne()
- {
- ClusterMembership membership = CreateWithCluster();
-
- const string oldThumbprint = "2222222222222222222222222222222222222222222222222222222222222222";
- const string newThumbprint = "4444444444444444444444444444444444444444444444444444444444444444";
-
- var peerId = Guid.NewGuid();
- var theirRoster = new Roster(peerId);
- theirRoster.Enroll(peerId, oldThumbprint, "Laptop");
- membership.Merge(theirRoster.Snapshot());
- Assert.True(membership.Pins.IsTrusted(oldThumbprint));
-
- // The peer reinstalled and generated a fresh key. Rotating a compromised certificate has
- // to actually retire the old one, or the rotation bought nothing.
- theirRoster.Enroll(peerId, newThumbprint, "Laptop");
- membership.Merge(theirRoster.Snapshot());
-
- Assert.True(membership.Pins.IsTrusted(newThumbprint));
- Assert.False(membership.Pins.IsTrusted(oldThumbprint));
- }
-
- [Fact]
- public void ImportingReplacesTheSetWithoutEverLeavingALivePinAbsent()
- {
- // Import is called on every roster change while handshakes may be in flight. Clearing
- // first would open a window where a peer that is trusted before and after is refused in
- // between, which reads as a flaky network and never reproduces on demand.
- ClusterMembership membership = CreateWithCluster();
- const string kept = "2222222222222222222222222222222222222222222222222222222222222222";
-
- EnrollPeer(membership, kept, "Kept");
- Guid dropped = EnrollPeer(
- membership, "5555555555555555555555555555555555555555555555555555555555555555", "Dropped");
-
- membership.RemoveMachine(dropped);
-
- Assert.True(membership.Pins.IsTrusted(kept));
- Assert.Equal(1, membership.Pins.Count);
- }
-
- public void Dispose()
- {
- GC.SuppressFinalize(this);
-
- if (Directory.Exists(_directory))
- {
- Directory.Delete(_directory, recursive: true);
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs
deleted file mode 100644
index 86b76e1..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/RosterTests.cs
+++ /dev/null
@@ -1,286 +0,0 @@
-using VirtualKmSwitch.Cluster;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class RosterTests
-{
- private static readonly Guid NodeA = new("11111111-1111-1111-1111-111111111111");
- private static readonly Guid NodeB = new("22222222-2222-2222-2222-222222222222");
- private static readonly Guid NodeC = new("33333333-3333-3333-3333-333333333333");
-
- private static string Fingerprint(Guid nodeId) => nodeId.ToString("N")[..16].ToUpperInvariant();
-
- [Fact]
- public void EnrollingAddsAMember()
- {
- var roster = new Roster(NodeA);
- roster.Enroll(NodeB, Fingerprint(NodeB), "Bob");
-
- Assert.True(roster.IsEnrolled(NodeB));
- Assert.Equal(1, roster.EnrolledCount);
- Assert.Equal("Bob", roster.Find(NodeB)!.DisplayName);
- }
-
- [Fact]
- public void RemovingLeavesATombstoneRatherThanDeleting()
- {
- // The tombstone is what makes the removal survive a merge with a peer that never saw it.
- var roster = new Roster(NodeA);
- roster.Enroll(NodeB, Fingerprint(NodeB));
- roster.Remove(NodeB);
-
- Assert.False(roster.IsEnrolled(NodeB));
- Assert.Equal(0, roster.EnrolledCount);
- Assert.Single(roster.AllEntries);
- Assert.Equal(MembershipState.Removed, roster.Find(NodeB)!.State);
- }
-
- [Fact]
- public void APeerWithAStaleCopyCannotResurrectARemovedMachine()
- {
- // The scenario the tombstone exists for: a machine powered off during a removal, merging
- // its older view back in when it returns.
- var authoritative = new Roster(NodeA);
- authoritative.Enroll(NodeB, Fingerprint(NodeB));
-
- var stale = new Roster(NodeC);
- stale.Merge(authoritative.Snapshot());
-
- authoritative.Remove(NodeB);
- authoritative.Merge(stale.Snapshot());
-
- Assert.False(authoritative.IsEnrolled(NodeB));
-
- // And the stale peer learns of the removal when it merges the other way.
- stale.Merge(authoritative.Snapshot());
- Assert.False(stale.IsEnrolled(NodeB));
- }
-
- [Fact]
- public void ReEnrollingAfterRemovalWins()
- {
- // Deliberately re-adding a machine has to beat the tombstone, or a removal would be
- // permanent and the machine could never rejoin.
- var roster = new Roster(NodeA);
- roster.Enroll(NodeB, Fingerprint(NodeB));
- roster.Remove(NodeB);
- roster.Enroll(NodeB, "NEWTHUMBPRINT");
-
- Assert.True(roster.IsEnrolled(NodeB));
- Assert.Equal("NEWTHUMBPRINT", roster.Find(NodeB)!.Thumbprint);
- }
-
- [Fact]
- public void MergingIsIdempotent()
- {
- var left = new Roster(NodeA);
- left.Enroll(NodeB, Fingerprint(NodeB), "Bob");
-
- var right = new Roster(NodeC);
- right.Merge(left.Snapshot());
-
- RosterSnapshot before = right.Snapshot();
- right.Merge(left.Snapshot());
- right.Merge(left.Snapshot());
-
- Assert.Equal(before.Entries.Count, right.Snapshot().Entries.Count);
- Assert.True(right.IsEnrolled(NodeB));
- }
-
- [Fact]
- public void MergingIsCommutative()
- {
- var alice = new Roster(NodeA);
- alice.Enroll(NodeB, Fingerprint(NodeB), "Bob");
-
- var carol = new Roster(NodeC);
- carol.Enroll(NodeC, Fingerprint(NodeC), "Carol");
-
- var aliceThenCarol = new Roster(Guid.NewGuid());
- aliceThenCarol.Merge(alice.Snapshot());
- aliceThenCarol.Merge(carol.Snapshot());
-
- var carolThenAlice = new Roster(Guid.NewGuid());
- carolThenAlice.Merge(carol.Snapshot());
- carolThenAlice.Merge(alice.Snapshot());
-
- AssertSameMembership(aliceThenCarol, carolThenAlice);
- }
-
- [Fact]
- public void ConcurrentEnrolAndRemoveResolveTheSameWayOnBothSides()
- {
- // A partition: one side removes a machine while the other re-enrolls it. Both must land on
- // the same answer, whichever order they exchange rosters in.
- var shared = new Roster(NodeA);
- shared.Enroll(NodeB, Fingerprint(NodeB));
-
- var remover = new Roster(NodeA);
- remover.Load(shared.Snapshot());
- remover.Remove(NodeB);
-
- var reEnroller = new Roster(NodeC);
- reEnroller.Load(shared.Snapshot());
- reEnroller.Enroll(NodeB, "REENROLLED");
-
- var wayOne = new Roster(Guid.NewGuid());
- wayOne.Merge(remover.Snapshot());
- wayOne.Merge(reEnroller.Snapshot());
-
- var wayTwo = new Roster(Guid.NewGuid());
- wayTwo.Merge(reEnroller.Snapshot());
- wayTwo.Merge(remover.Snapshot());
-
- Assert.Equal(wayOne.IsEnrolled(NodeB), wayTwo.IsEnrolled(NodeB));
- Assert.Equal(wayOne.Find(NodeB)!.Thumbprint, wayTwo.Find(NodeB)!.Thumbprint);
- }
-
- [Fact]
- public void AMachineOwnsItsOwnName()
- {
- // Membership and name have separate stamps, so a remover's stale copy of the name cannot
- // overwrite what the machine calls itself.
- var machine = new Roster(NodeB);
- machine.Enroll(NodeB, Fingerprint(NodeB), "old-name");
- machine.SetDisplayName(NodeB, "renamed-by-owner");
-
- var other = new Roster(NodeA);
- other.Enroll(NodeB, Fingerprint(NodeB), "old-name");
- other.Merge(machine.Snapshot());
-
- Assert.Equal("renamed-by-owner", other.Find(NodeB)!.DisplayName);
- }
-
- [Fact]
- public void MergingAdvancesTheClockPastEverythingObserved()
- {
- // A node returning from a long absence must not keep issuing stamps that lose to changes
- // it has just learned about.
- var busy = new Roster(NodeA);
- for (int i = 0; i < 20; i++)
- {
- busy.Enroll(Guid.NewGuid(), $"THUMB{i}");
- }
-
- var quiet = new Roster(NodeC);
- Assert.Equal(0UL, quiet.Clock);
-
- quiet.Merge(busy.Snapshot());
- Assert.True(quiet.Clock >= busy.Clock);
-
- // A change made now outranks everything it merged.
- quiet.Enroll(NodeB, Fingerprint(NodeB));
- Assert.True(quiet.Find(NodeB)!.StateChangedAt.Counter > busy.Clock);
- }
-
- [Fact]
- public void EqualStampsAreBrokenDeterministically()
- {
- // Two nodes acting at the same logical time must still converge; without a tie-break each
- // would keep its own and the meshes would never agree.
- var lower = new LamportStamp(5, NodeA);
- var higher = new LamportStamp(5, NodeC);
-
- Assert.NotEqual(0, lower.CompareTo(higher));
- Assert.Equal(-Math.Sign(lower.CompareTo(higher)), Math.Sign(higher.CompareTo(lower)));
- }
-
- ///
- /// The property that matters: any set of changes, delivered in any order, converges.
- ///
- /// Enumerated cases only cover the orderings someone thought of. This runs randomised
- /// histories against three replicas exchanging in different sequences, which is where a
- /// non-commutative merge actually shows up.
- ///
- [Theory]
- [InlineData(1)]
- [InlineData(7)]
- [InlineData(42)]
- [InlineData(1337)]
- [InlineData(90210)]
- public void AnyDeliveryOrderConverges(int seed)
- {
- var random = new Random(seed);
- Guid[] nodes = [NodeA, NodeB, NodeC];
- Guid[] machines = [.. Enumerable.Range(0, 6).Select(_ => Guid.NewGuid())];
-
- Roster[] replicas = [.. nodes.Select(node => new Roster(node))];
-
- // Each replica applies its own random history, entirely independently.
- for (int round = 0; round < 60; round++)
- {
- Roster replica = replicas[random.Next(replicas.Length)];
- Guid machine = machines[random.Next(machines.Length)];
-
- switch (random.Next(3))
- {
- case 0:
- replica.Enroll(machine, $"THUMB-{random.Next(1000)}");
- break;
-
- case 1:
- replica.Remove(machine);
- break;
-
- default:
- replica.SetDisplayName(machine, $"name-{random.Next(100)}");
- break;
- }
- }
-
- RosterSnapshot[] snapshots = [.. replicas.Select(replica => replica.Snapshot())];
-
- // Exchange everything with everyone, repeatedly and in shuffled order, which is how a real
- // mesh gossips.
- for (int pass = 0; pass < 4; pass++)
- {
- foreach (Roster replica in replicas.OrderBy(_ => random.Next()))
- {
- foreach (RosterSnapshot snapshot in snapshots.OrderBy(_ => random.Next()))
- {
- replica.Merge(snapshot);
- }
- }
-
- snapshots = [.. replicas.Select(replica => replica.Snapshot())];
- }
-
- for (int i = 1; i < replicas.Length; i++)
- {
- AssertSameMembership(replicas[0], replicas[i]);
- }
- }
-
- [Fact]
- public void SnapshotsRoundTripThroughLoad()
- {
- var original = new Roster(NodeA);
- original.Enroll(NodeB, Fingerprint(NodeB), "Bob");
- original.Enroll(NodeC, Fingerprint(NodeC), "Carol");
- original.Remove(NodeC);
-
- var restored = new Roster(NodeA);
- restored.Load(original.Snapshot());
-
- AssertSameMembership(original, restored);
- Assert.Equal(original.Clock, restored.Clock);
- }
-
- private static void AssertSameMembership(Roster left, Roster right)
- {
- Dictionary leftEntries = left.AllEntries.ToDictionary(entry => entry.NodeId);
- Dictionary rightEntries = right.AllEntries.ToDictionary(entry => entry.NodeId);
-
- Assert.Equal(leftEntries.Keys.OrderBy(id => id), rightEntries.Keys.OrderBy(id => id));
-
- foreach ((Guid nodeId, RosterEntry leftEntry) in leftEntries)
- {
- RosterEntry rightEntry = rightEntries[nodeId];
-
- Assert.Equal(leftEntry.State, rightEntry.State);
- Assert.Equal(leftEntry.Thumbprint, rightEntry.Thumbprint);
- Assert.Equal(leftEntry.DisplayName, rightEntry.DisplayName);
- }
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs
deleted file mode 100644
index 428d35a..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/SharedMemoryRingTests.cs
+++ /dev/null
@@ -1,236 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Ipc;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-///
-/// Exercises the cross-process ring within one process. Both ends map the same named region, so
-/// the shared-memory path, the header handshake and the index arithmetic are all real; only the
-/// process boundary is absent.
-///
-public class SharedMemoryRingTests
-{
- ///
- /// Named per test so parallel runs cannot collide. Created in the Local namespace without the
- /// SYSTEM-only DACL, because the test process is an ordinary user: Global needs
- /// SeCreateGlobalPrivilege, which only the real service has. The ring logic under test is
- /// identical either way.
- ///
- private static SharedMemoryRing CreateRing(string suffix, int capacity = 64) =>
- SharedMemoryRing.Create(
- $"Test.{suffix}.{Environment.ProcessId}", capacity, systemOnly: false, globalNamespace: false);
-
- private static SharedMemoryRing OpenRing(string suffix, int capacity = 64) =>
- SharedMemoryRing.Open(
- $"Test.{suffix}.{Environment.ProcessId}", capacity, globalNamespace: false);
-
- [Fact]
- public void ItemsSurviveTheSharedMemoryRoundTrip()
- {
- using SharedMemoryRing writer = CreateRing(nameof(ItemsSurviveTheSharedMemoryRoundTrip));
- using SharedMemoryRing reader = OpenRing(nameof(ItemsSurviveTheSharedMemoryRoundTrip));
-
- for (int i = 1; i <= 10; i++)
- {
- Assert.True(writer.TryWrite(InputEvent.MouseMove(i, i, -i)));
- }
-
- var received = new InputEvent[32];
- int count = reader.ReadBatch(received);
-
- Assert.Equal(10, count);
- for (int i = 0; i < 10; i++)
- {
- Assert.Equal(i + 1, received[i].X);
- Assert.Equal(-(i + 1), received[i].Y);
- Assert.Equal(i + 1, received[i].Timestamp);
- }
- }
-
- [Fact]
- public void EveryFieldCrossesTheBoundaryIntact()
- {
- // The struct is copied as raw bytes through shared memory, so a layout or packing mistake
- // would silently corrupt fields rather than fail loudly.
- using SharedMemoryRing writer = CreateRing(nameof(EveryFieldCrossesTheBoundaryIntact));
- using SharedMemoryRing reader = OpenRing(nameof(EveryFieldCrossesTheBoundaryIntact));
-
- var original = new InputEvent(
- long.MaxValue,
- InputEventKind.Key,
- InputEventFlags.Extended | InputEventFlags.Up,
- 0x1D,
- int.MinValue,
- int.MaxValue);
-
- writer.TryWrite(original);
-
- var received = new InputEvent[1];
- Assert.Equal(1, reader.ReadBatch(received));
- Assert.Equal(original, received[0]);
- }
-
- [Fact]
- public void FullRingRefusesWritesAndCountsThem()
- {
- using SharedMemoryRing ring = CreateRing(nameof(FullRingRefusesWritesAndCountsThem), 4);
-
- for (int i = 0; i < ring.Capacity; i++)
- {
- Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0)));
- }
-
- Assert.False(ring.TryWrite(InputEvent.MouseMove(99, 99, 0)));
- Assert.Equal(1, ring.RejectedWrites);
- }
-
- [Fact]
- public void IndicesWrapWithoutCorruption()
- {
- using SharedMemoryRing writer = CreateRing(nameof(IndicesWrapWithoutCorruption), 4);
- using SharedMemoryRing reader = OpenRing(nameof(IndicesWrapWithoutCorruption), 4);
-
- var received = new InputEvent[4];
- for (int i = 0; i < 1000; i++)
- {
- Assert.True(writer.TryWrite(InputEvent.MouseMove(i, i, 0)));
- Assert.Equal(1, reader.ReadBatch(received));
- Assert.Equal(i, received[0].X);
- }
- }
-
- [Fact]
- public void MismatchedCapacityIsRejectedRatherThanMisread()
- {
- // Both sides compute slot addresses from the capacity. Disagreeing about it would not
- // fail — it would silently read the wrong memory, so the header is checked on open.
- using SharedMemoryRing writer = CreateRing(nameof(MismatchedCapacityIsRejectedRatherThanMisread), 64);
-
- Assert.Throws(
- () => OpenRing(nameof(MismatchedCapacityIsRejectedRatherThanMisread), 128));
- }
-
- [Fact]
- public void DiscardPendingDropsStaleInput()
- {
- // The ring outlives an agent that crashes. Replaying what it had queued would inject
- // keystrokes captured seconds ago into whatever has focus now.
- using SharedMemoryRing writer = CreateRing(nameof(DiscardPendingDropsStaleInput));
- using SharedMemoryRing reader = OpenRing(nameof(DiscardPendingDropsStaleInput));
-
- for (int i = 0; i < 10; i++)
- {
- writer.TryWrite(InputEvent.MouseMove(i, i, 0));
- }
-
- reader.DiscardPending();
-
- Assert.Equal(0, reader.ReadBatch(new InputEvent[32]));
-
- // Still usable afterwards.
- writer.TryWrite(InputEvent.MouseMove(99, 5, 5));
- Assert.Equal(1, reader.ReadBatch(new InputEvent[32]));
- }
-
- [Fact]
- public void SignalWakesAWaitingConsumer()
- {
- using SharedMemoryRing writer = CreateRing(nameof(SignalWakesAWaitingConsumer));
- using SharedMemoryRing reader = OpenRing(nameof(SignalWakesAWaitingConsumer));
-
- Assert.False(reader.WaitForData(TimeSpan.FromMilliseconds(50)));
-
- writer.TryWrite(InputEvent.MouseMove(1, 1, 1));
- writer.SignalDataAvailable();
-
- Assert.True(reader.WaitForData(TimeSpan.FromSeconds(2)));
- }
-
- ///
- /// The assertion the plan asks for at this step. Allocation on this path would eventually mean
- /// gen0 collections, and a GC pause reaching the hook thread risks the LowLevelHooksTimeout,
- /// after which Windows unhooks us silently.
- ///
- [Fact]
- public void SteadyStateTrafficAllocatesNothing()
- {
- using SharedMemoryRing writer = CreateRing(nameof(SteadyStateTrafficAllocatesNothing), 1024);
- using SharedMemoryRing reader = OpenRing(nameof(SteadyStateTrafficAllocatesNothing), 1024);
-
- var destination = new InputEvent[64];
-
- // Warm up so JIT and first-touch page faults are not counted.
- for (int i = 0; i < 100; i++)
- {
- writer.TryWrite(InputEvent.MouseMove(i, 1, 1));
- }
-
- reader.ReadBatch(destination);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
-
- for (int round = 0; round < 1000; round++)
- {
- for (int i = 0; i < 32; i++)
- {
- writer.TryWrite(InputEvent.MouseMove(i, i, i));
- }
-
- while (reader.ReadBatch(destination) > 0)
- {
- }
- }
-
- Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
- }
-
- [Fact]
- public async Task ConcurrentProducerAndConsumerLoseNothing()
- {
- const int total = 100_000;
-
- using SharedMemoryRing writer = CreateRing(nameof(ConcurrentProducerAndConsumerLoseNothing), 256);
- using SharedMemoryRing reader = OpenRing(nameof(ConcurrentProducerAndConsumerLoseNothing), 256);
-
- var producer = Task.Run(() =>
- {
- for (int i = 0; i < total; i++)
- {
- while (!writer.TryWrite(InputEvent.MouseMove(i, i, 0)))
- {
- Thread.SpinWait(1);
- }
- }
- });
-
- var consumer = Task.Run(() =>
- {
- var batch = new InputEvent[64];
- int expected = 0;
-
- while (expected < total)
- {
- int count = reader.ReadBatch(batch);
- if (count == 0)
- {
- Thread.SpinWait(1);
- continue;
- }
-
- for (int i = 0; i < count; i++)
- {
- // Order must hold exactly: a key-up overtaking its key-down would strand a
- // modifier held on a remote machine.
- Assert.Equal(expected, batch[i].X);
- expected++;
- }
- }
-
- return expected;
- });
-
- await producer;
- Assert.Equal(total, await consumer);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs b/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs
deleted file mode 100644
index ece59e4..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/SpscRingBufferTests.cs
+++ /dev/null
@@ -1,198 +0,0 @@
-using VirtualKmSwitch.Core;
-using VirtualKmSwitch.Ipc;
-using Xunit;
-
-namespace VirtualKmSwitch.Tests;
-
-public class SpscRingBufferTests
-{
- [Fact]
- public void CapacityRoundsUpToPowerOfTwo()
- {
- Assert.Equal(8, new SpscRingBuffer(5).Capacity);
- Assert.Equal(1024, new SpscRingBuffer(1024).Capacity);
- }
-
- [Fact]
- public void WritesAreReadBackInOrder()
- {
- var ring = new SpscRingBuffer(16);
-
- for (int i = 1; i <= 10; i++)
- {
- Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, -i)));
- }
-
- for (int i = 1; i <= 10; i++)
- {
- Assert.True(ring.TryRead(out InputEvent read));
- Assert.Equal(i, read.X);
- Assert.Equal(-i, read.Y);
- Assert.Equal(i, read.Timestamp);
- }
-
- Assert.False(ring.TryRead(out _));
- }
-
- [Fact]
- public void RefusesWritesWhenFullAndCountsThem()
- {
- var ring = new SpscRingBuffer(4);
-
- for (int i = 0; i < 4; i++)
- {
- Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0)));
- }
-
- Assert.False(ring.TryWrite(InputEvent.MouseMove(99, 99, 0)));
- Assert.Equal(1, ring.RejectedWrites);
-
- // Draining one slot makes room again.
- Assert.True(ring.TryRead(out _));
- Assert.True(ring.TryWrite(InputEvent.MouseMove(99, 99, 0)));
- }
-
- [Fact]
- public void IndicesWrapWithoutCorruption()
- {
- var ring = new SpscRingBuffer(4);
-
- // Many times capacity, so the mask arithmetic wraps repeatedly.
- for (int i = 0; i < 1000; i++)
- {
- Assert.True(ring.TryWrite(InputEvent.MouseMove(i, i, 0)));
- Assert.True(ring.TryRead(out InputEvent read));
- Assert.Equal(i, read.X);
- }
- }
-
- [Fact]
- public void ReadBatchDrainsWhatIsAvailable()
- {
- var ring = new SpscRingBuffer(64);
- for (int i = 0; i < 10; i++)
- {
- ring.TryWrite(InputEvent.MouseMove(i, i, 0));
- }
-
- var destination = new InputEvent[32];
- int read = ring.ReadBatch(destination);
-
- Assert.Equal(10, read);
- for (int i = 0; i < 10; i++)
- {
- Assert.Equal(i, destination[i].X);
- }
-
- Assert.Equal(0, ring.ReadBatch(destination));
- }
-
- [Fact]
- public void ReadBatchIsLimitedByDestinationLength()
- {
- var ring = new SpscRingBuffer(64);
- for (int i = 0; i < 20; i++)
- {
- ring.TryWrite(InputEvent.MouseMove(i, i, 0));
- }
-
- var small = new InputEvent[8];
- Assert.Equal(8, ring.ReadBatch(small));
- Assert.Equal(0, small[0].X);
- Assert.Equal(7, small[7].X);
- Assert.Equal(12, ring.Count);
- }
-
- ///
- /// The regression test that keeps the hot path honest. Every input event from capture to wire
- /// must allocate nothing: allocation means eventual gen0 collections, and a GC pause on the
- /// hook thread risks overrunning LowLevelHooksTimeout, which makes Windows silently unhook us.
- ///
- [Fact]
- public void SteadyStateTrafficAllocatesNothing()
- {
- var ring = new SpscRingBuffer(1024);
- var destination = new InputEvent[64];
-
- // Warm up so JIT and first-touch costs are not counted.
- for (int i = 0; i < 100; i++)
- {
- ring.TryWrite(InputEvent.MouseMove(i, 1, 1));
- }
-
- ring.ReadBatch(destination);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
-
- for (int round = 0; round < 1000; round++)
- {
- for (int i = 0; i < 32; i++)
- {
- ring.TryWrite(InputEvent.MouseMove(i, i, i));
- }
-
- while (ring.ReadBatch(destination) > 0)
- {
- }
- }
-
- long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
- Assert.Equal(0, allocated);
- }
-
- [Fact]
- public async Task ConcurrentProducerAndConsumerLoseNothing()
- {
- const int total = 200_000;
- var ring = new SpscRingBuffer(256);
-
- var producer = Task.Run(() =>
- {
- for (int i = 0; i < total; i++)
- {
- // Full buffer is expected under load; spin rather than drop, so the test measures
- // correctness rather than backpressure policy.
- while (!ring.TryWrite(InputEvent.MouseMove(i, i, 0)))
- {
- Thread.SpinWait(1);
- }
- }
- });
-
- var consumer = Task.Run(() =>
- {
- var batch = new InputEvent[64];
- int expected = 0;
-
- while (expected < total)
- {
- int count = ring.ReadBatch(batch);
- if (count == 0)
- {
- Thread.SpinWait(1);
- continue;
- }
-
- for (int i = 0; i < count; i++)
- {
- // Order must be exact: a key-up overtaking its key-down would strand a
- // modifier held on a remote machine.
- Assert.Equal(expected, batch[i].X);
- expected++;
- }
- }
-
- return expected;
- });
-
- await producer;
- int received = await consumer;
-
- Assert.Equal(total, received);
-
- // Rejections are expected here -- a 256-slot ring cannot hold 200k events, and the
- // producer spins rather than dropping. What matters is that nothing was lost or
- // reordered, which the consumer asserted event by event.
- Assert.True(ring.RejectedWrites >= 0);
- }
-}
diff --git a/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj b/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj
deleted file mode 100644
index 3f888f4..0000000
--- a/src/csharp/tests/VirtualKmSwitch.Tests/VirtualKmSwitch.Tests.csproj
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-