diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa11dd7..a928ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: Upload compatibility validation bundle uses: actions/upload-artifact@v7 with: - name: HyperVConsoleKit-0.6.0-preview.1-validation + name: HyperVConsoleKit-0.6.0-preview.2-validation path: | artifacts/compatibility-probe/** artifacts/packages/** diff --git a/README.md b/README.md index 42e71dc..167b077 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,8 @@ Leaving stream `Width` and `Height` as `0` uses the VM's recommended video-head Your callback is awaited. If `DropFramesWhenBehind` is `true`, HyperVConsoleKit uses latest-frame streaming internally: capture can keep moving while your sender is busy, and the sender receives the newest available frame instead of working through stale frames. +Accepted keyboard or mouse input also interrupts an adaptive stream's idle delay. The next capture uses the active frame rate even when the first post-input capture has not changed yet, avoiding an idle-rate wait before visual feedback. + Placeholder methods from the example: ```csharp @@ -419,7 +421,9 @@ await manager.AddViewerAsync( Set `policy.IdleTimeout` to keep an empty shared stream warm briefly for reconnects. Viewers sharing one VM must request equivalent stream options; a mismatched request is rejected instead of silently inheriting the first -viewer's settings. Late viewers receive a cached full keyframe before tile deltas. +viewer's settings. The hub reconstructs the current framebuffer so late viewers receive a current full keyframe. If a slow viewer skips an incremental tile dependency, its next queued update is replaced with a current recovery keyframe instead of leaving stale pixels behind. + +Each streamed `ConsoleFrame` reports capture-lock wait, Hyper-V capture time, pixel-processing time, raw captures dropped before processing, and whether the hub synthesized a recovery keyframe. These fields are intended for before/after performance measurements on the target Hyper-V host. ## Streaming Presets @@ -702,12 +706,20 @@ Run diagnostics: dotnet run --project samples\ConsoleDiagnostics\ConsoleDiagnostics.csproj -- "My VM" ``` -Diagnostics does not send keyboard input by default. Add `--send-input` to include a simple Enter key smoke test: +Diagnostics does not send guest input by default. Add `--send-input` to include mouse movement and a simple Enter key smoke test: ```powershell dotnet run --project samples\ConsoleDiagnostics\ConsoleDiagnostics.csproj -- "My VM" --send-input ``` +To compare the existing Balanced and Latency presets without sending guest input, add a bounded performance window: + +```powershell +dotnet run --project samples\ConsoleDiagnostics\ConsoleDiagnostics.csproj -- "My VM" --performance-seconds=15 +``` + +The report includes delivered FPS and payload rate plus p50/p95 capture, per-VM CIM-lock wait, and pixel-processing timings. The web sample additionally displays input-to-painted-frame latency in the browser. + Run the browser sample: ```powershell @@ -736,8 +748,8 @@ It does include practical gateway hooks in `appsettings.json`: "InputLeaseMinutes": 5, "MaxWidth": 1024, "MaxHeight": 768, - "MaxFramesPerSecond": 5, - "MaxBytesPerSecond": 500000, + "MaxFramesPerSecond": 8, + "MaxBytesPerSecond": 900000, "MaxColorDepth": "Rgb332", "MaxConcurrentViewers": 3, "AllowKeyboardInput": true, @@ -790,8 +802,12 @@ The JSON header includes: - pixel format - update kind - keyframe flag +- recovery-keyframe flag - payload byte count - tile metadata +- capture-lock, capture, and pixel-processing timings +- capture-drop count +- the latest input sequence captured after input completion You can copy this idea, replace it with your own protocol, or send `ConsoleFrame` through SignalR, gRPC, WebRTC data channels, a relay, or your existing agent transport. diff --git a/docs/COMPATIBILITY_VALIDATION.md b/docs/COMPATIBILITY_VALIDATION.md index fd4366a..137d6a7 100644 --- a/docs/COMPATIBILITY_VALIDATION.md +++ b/docs/COMPATIBILITY_VALIDATION.md @@ -11,16 +11,18 @@ published. | Windows Server 2016 x64 | Pending | Must include the host exhibiting the original `wminet_utils.dll` failure | | Windows Server 2019 x64 | Pending | Required physical Hyper-V host | | Windows Server 2022 x64 | Pending | Required physical Hyper-V host | -| Windows Server 2025 x64 | Passed | Build 26100; local Hyper-V probe passed | +| Windows Server 2025 x64 | Pending | `0.6.0-preview.1` passed on build 26100; the current package requires a fresh run | | Windows 10 x64 | Pending | Required physical Hyper-V host | | Windows 11 x64 | Pending | Required physical Hyper-V host | -The Server 2025 run passed VM enumeration and lookup, capability discovery, first and sustained capture, +The `0.6.0-preview.1` Server 2025 run passed VM enumeration and lookup, capability discovery, first and sustained capture, recommended-size capture, mouse movement and click, Enter, Shift+Tab, Ctrl+Alt+Del, repeated open/close, client disposal, asynchronous-job VM start, force-stop with restoration to the original offline state, and absence of both `System.Management.dll` and `wminet_utils.dll`. The unchanged self-contained probe passed once as an elevated administrator and once as `NT AUTHORITY\SYSTEM`. +That result is historical evidence and does not mark the current package as physically validated. + ## Build the unchanged probe ```powershell diff --git a/docs/PACKAGE_DEPENDENCIES.md b/docs/PACKAGE_DEPENDENCIES.md index 7dfca24..12b163f 100644 --- a/docs/PACKAGE_DEPENDENCIES.md +++ b/docs/PACKAGE_DEPENDENCIES.md @@ -1,6 +1,6 @@ # Package dependency report -Package: `HyperVConsoleKit 0.6.0-preview.1` +Package: `HyperVConsoleKit 0.6.0-preview.2` Target: `net8.0-windows7.0` diff --git a/samples/AspNetCoreWebConsole/Program.cs b/samples/AspNetCoreWebConsole/Program.cs index bd72ae2..febde21 100644 --- a/samples/AspNetCoreWebConsole/Program.cs +++ b/samples/AspNetCoreWebConsole/Program.cs @@ -29,6 +29,7 @@ var sessionManager = new HyperVConsoleSessionManager(client, policy); using var inputLeases = new InputLeaseManager(client, policy, TimeSpan.FromMinutes(webOptions.InputLeaseMinutes)); var auditLog = new ConcurrentQueue(); +var inputCompletions = new ConcurrentDictionary(); client.Activity += (_, e) => { auditLog.Enqueue(e); @@ -137,22 +138,24 @@ }); }); -app.MapPost("/api/vms/{id:guid}/keys/{key}/down", async (Guid id, string key, string lease, HttpContext context) => +app.MapPost("/api/vms/{id:guid}/keys/{key}/down", async (Guid id, string key, string lease, long? sequence, HttpContext context) => { return await SafeAsync(async () => { EnsureAllowedVm(id, webOptions); await inputLeases.KeyDownAsync(lease, id, ParseKeyCode(key), context.RequestAborted); + RecordInputCompletion(inputCompletions, id, lease, sequence); return Results.Ok(); }); }); -app.MapPost("/api/vms/{id:guid}/keys/{key}/up", async (Guid id, string key, string lease, HttpContext context) => +app.MapPost("/api/vms/{id:guid}/keys/{key}/up", async (Guid id, string key, string lease, long? sequence, HttpContext context) => { return await SafeAsync(async () => { EnsureAllowedVm(id, webOptions); await inputLeases.KeyUpAsync(lease, id, ParseKeyCode(key), context.RequestAborted); + RecordInputCompletion(inputCompletions, id, lease, sequence); return Results.Ok(); }); }); @@ -188,13 +191,19 @@ }); }); -app.MapPost("/api/vms/{id:guid}/mouse/click", (Guid id, int x, int y, MouseButton button) => +app.MapPost("/api/vms/{id:guid}/mouse/click", (Guid id, int x, int y, MouseButton button, string lease, long? sequence) => { return SafeResult(() => { EnsureAllowedVm(id, webOptions); using var session = client.OpenConsole(id, new HyperVConsoleOpenOptions { Mode = HyperVConsoleMode.RawHostConsole, Policy = policy }); - return session.TrySendMouseClick(x, y, button) ? Results.Ok() : Results.BadRequest(new { error = "Mouse input is not available for this VM." }); + if (!session.TrySendMouseClick(x, y, button)) + { + return Results.BadRequest(new { error = "Mouse input is not available for this VM." }); + } + + RecordInputCompletion(inputCompletions, id, lease, sequence); + return Results.Ok(); }); }); @@ -277,7 +286,8 @@ await sessionManager.AddViewerAsync(id, options, async (frame, cancellationToken { if (socket.State == WebSocketState.Open) { - await SendConsoleFrameAsync(socket, frame, cancellationToken); + inputCompletions.TryGetValue(GetInputCompletionKey(id, lease), out var inputCompletion); + await SendConsoleFrameAsync(socket, frame, inputCompletion, cancellationToken); return; } @@ -297,6 +307,7 @@ await sessionManager.AddViewerAsync(id, options, async (frame, cancellationToken finally { await inputLeases.ReleaseAsync(lease, id); + inputCompletions.TryRemove(GetInputCompletionKey(id, lease), out _); } }); @@ -527,7 +538,19 @@ static void EnsureAllowedVm(Guid id, HyperVConsoleWebOptions options) } } -static async Task SendConsoleFrameAsync(WebSocket socket, ConsoleFrame frame, CancellationToken cancellationToken) +static void RecordInputCompletion(ConcurrentDictionary inputCompletions, Guid virtualMachineId, string lease, long? sequence) +{ + if (string.IsNullOrWhiteSpace(lease) || !sequence.HasValue || sequence.Value <= 0) + { + return; + } + + inputCompletions[GetInputCompletionKey(virtualMachineId, lease)] = new InputCompletion(sequence.Value, DateTime.UtcNow); +} + +static string GetInputCompletionKey(Guid virtualMachineId, string lease) => virtualMachineId.ToString("D") + ":" + lease; + +static async Task SendConsoleFrameAsync(WebSocket socket, ConsoleFrame frame, InputCompletion? inputCompletion, CancellationToken cancellationToken) { var payload = frame.UpdateKind == ConsoleFrameUpdateKind.FullFrame ? frame.RawBytes ?? Array.Empty() @@ -536,13 +559,26 @@ static async Task SendConsoleFrameAsync(WebSocket socket, ConsoleFrame frame, Ca var header = new { sequenceNumber = frame.SequenceNumber, + capturedUtc = frame.CapturedUtc, width = frame.Width, height = frame.Height, pixelFormat = frame.PixelFormat.ToString(), updateKind = frame.UpdateKind.ToString(), isKeyFrame = frame.IsKeyFrame, + isRecoveryKeyFrame = frame.IsRecoveryKeyFrame, payloadBytes = payload.Length, targetFramesPerSecond = frame.TargetFramesPerSecond, + captureLockWaitMilliseconds = frame.CaptureLockWaitMilliseconds, + captureDurationMilliseconds = frame.CaptureDurationMilliseconds, + cimQueryDurationMilliseconds = frame.CimQueryDurationMilliseconds, + cimAssociationDurationMilliseconds = frame.CimAssociationDurationMilliseconds, + cimMetadataDurationMilliseconds = frame.CimMetadataDurationMilliseconds, + cimInvokeDurationMilliseconds = frame.CimInvokeDurationMilliseconds, + processingDurationMilliseconds = frame.ProcessingDurationMilliseconds, + droppedCaptureFrames = frame.DroppedCaptureFrames, + acknowledgedInputSequence = inputCompletion != null && frame.CapturedUtc >= inputCompletion.CompletedUtc + ? inputCompletion.Sequence + : 0, tiles = frame.Tiles.Select(tile => { var item = new @@ -686,6 +722,9 @@ static string GetHtml() let selected; let socket; let inputQueue = Promise.resolve(); + let inputSequence = 0; + let lastInputToPaintMs; + const pendingInput = new Map(); const pressedKeys = new Map(); async function loadVms() { @@ -707,6 +746,8 @@ function selectVm(vm) { } inputLease = crypto.randomUUID(); pressedKeys.clear(); + pendingInput.clear(); + lastInputToPaintMs = undefined; selected = vm; [...list.children].forEach(child => child.setAttribute('aria-selected', child.textContent.includes(vm.id) ? 'true' : 'false')); if (socket) socket.close(); @@ -717,9 +758,15 @@ function selectVm(vm) { const preset = document.getElementById('preset').value; const tiles = document.getElementById('tiles').checked; const scheme = location.protocol === 'https:' ? 'wss' : 'ws'; - const params = new URLSearchParams({ fps, idleFps, format, preset, tiles }); + const params = new URLSearchParams({ preset }); params.set('lease', inputLease); - if (maxBps) params.set('maxBps', maxBps); + if (preset === 'Custom') { + params.set('fps', fps); + params.set('idleFps', idleFps); + params.set('format', format); + params.set('tiles', tiles); + if (maxBps) params.set('maxBps', maxBps); + } socket = new WebSocket(`${scheme}://${location.host}/ws/console/${vm.id}?${params}`); socket.binaryType = 'arraybuffer'; socket.onopen = () => statusEl.textContent = `${vm.name} connected`; @@ -728,7 +775,17 @@ function selectVm(vm) { socket.onmessage = event => { const frame = parseFrame(event.data); drawFrame(frame); - statusEl.textContent = `${vm.name} ${frame.header.pixelFormat} ${frame.header.updateKind} ${Math.round(frame.header.payloadBytes / 1024)} KB`; + const acknowledgedInputSequence = frame.header.acknowledgedInputSequence || 0; + const inputStarted = pendingInput.get(acknowledgedInputSequence); + if (inputStarted !== undefined) { + lastInputToPaintMs = performance.now() - inputStarted; + } + for (const sequence of pendingInput.keys()) { + if (sequence <= acknowledgedInputSequence) pendingInput.delete(sequence); + } + const recovery = frame.header.isRecoveryKeyFrame ? ' recovery' : ''; + const inputTiming = lastInputToPaintMs === undefined ? '' : ` | input-to-paint ${lastInputToPaintMs.toFixed(1)} ms`; + statusEl.textContent = `${vm.name} ${frame.header.pixelFormat} ${frame.header.updateKind}${recovery} ${Math.round(frame.header.payloadBytes / 1024)} KB | capture ${frame.header.captureDurationMilliseconds.toFixed(1)} ms | wait ${frame.header.captureLockWaitMilliseconds.toFixed(1)} ms | process ${frame.header.processingDurationMilliseconds.toFixed(1)} ms | drops ${frame.header.droppedCaptureFrames}${inputTiming}`; }; } @@ -740,7 +797,7 @@ function selectVm(vm) { const guestHeight = selected.recommendedFrameHeight || canvas.height; const x = Math.max(0, Math.min(guestWidth - 1, Math.floor(((event.clientX - rect.left) / rect.width) * guestWidth))); const y = Math.max(0, Math.min(guestHeight - 1, Math.floor(((event.clientY - rect.top) / rect.height) * guestHeight))); - post(`/api/vms/{id}/mouse/click?x=${x}&y=${y}&button=Left`, undefined, false); + queueInput(`/api/vms/{id}/mouse/click?x=${x}&y=${y}&button=Left&lease=${encodeURIComponent(inputLease)}`); }; canvas.addEventListener('keydown', event => { @@ -803,10 +860,17 @@ function toConsoleKey(code) { function queueInput(path) { const vmId = selected && selected.id; if (!vmId) return inputQueue; - const resolvedPath = path.replace('{id}', vmId); + const sequence = ++inputSequence; + const separator = path.includes('?') ? '&' : '?'; + const resolvedPath = `${path.replace('{id}', vmId)}${separator}sequence=${sequence}`; + pendingInput.set(sequence, performance.now()); inputQueue = inputQueue .then(() => post(resolvedPath, undefined, false)) - .catch(error => { statusEl.textContent = error.message || 'Input request failed'; }); + .then(response => { if (!response || !response.ok) pendingInput.delete(sequence); }) + .catch(error => { + pendingInput.delete(sequence); + statusEl.textContent = error.message || 'Input request failed'; + }); return inputQueue; } @@ -818,6 +882,7 @@ async function post(path, body, refreshVms = true) { statusEl.textContent = problem.error || problem.detail || response.statusText; } if (refreshVms) setTimeout(loadVms, 500); + return response; } async function apiFetch(path, init = {}) { @@ -831,6 +896,20 @@ async function apiFetch(path, init = {}) { document.getElementById('sendText').onclick = () => post('/api/vms/{id}/text', document.getElementById('text').value, false); document.getElementById('pasteText').onclick = () => post('/api/vms/{id}/paste', document.getElementById('text').value, false); document.getElementById('reconnect').onclick = () => selected && selectVm(selected); + document.getElementById('preset').onchange = event => { + const preset = ({ + Latency: { fps: 8, idleFps: 2, maxBps: 900000, format: 'Rgb332', tiles: true }, + Balanced: { fps: 5, idleFps: 1, maxBps: 500000, format: 'Rgb332', tiles: true }, + LowBandwidth: { fps: 3, idleFps: 0.5, maxBps: 180000, format: 'Gray8', tiles: true }, + Quality: { fps: 5, idleFps: 1, maxBps: '', format: 'Rgb565', tiles: false } + })[event.target.value]; + if (!preset) return; + document.getElementById('fps').value = preset.fps; + document.getElementById('idleFps').value = preset.idleFps; + document.getElementById('maxBps').value = preset.maxBps; + document.getElementById('format').value = preset.format; + document.getElementById('tiles').checked = preset.tiles; + }; document.getElementById('start').onclick = () => post('/api/vms/{id}/start'); document.getElementById('stop').onclick = () => post('/api/vms/{id}/stop'); document.getElementById('reset').onclick = () => post('/api/vms/{id}/reset'); @@ -916,6 +995,18 @@ function decodePixel(source, index, format) { """; } +public sealed class InputCompletion +{ + public InputCompletion(long sequence, DateTime completedUtc) + { + Sequence = sequence; + CompletedUtc = completedUtc; + } + + public long Sequence { get; } + public DateTime CompletedUtc { get; } +} + public sealed class HyperVConsoleWebOptions { public const string AuthenticationCookieName = "HyperVConsoleAuth"; @@ -928,8 +1019,8 @@ public sealed class HyperVConsoleWebOptions public int InputLeaseMinutes { get; set; } = 5; public int? MaxWidth { get; set; } = 1024; public int? MaxHeight { get; set; } = 768; - public double? MaxFramesPerSecond { get; set; } = 5; - public long? MaxBytesPerSecond { get; set; } = 500_000; + public double? MaxFramesPerSecond { get; set; } = 8; + public long? MaxBytesPerSecond { get; set; } = 900_000; public ConsoleFramePixelFormat? MaxColorDepth { get; set; } = ConsoleFramePixelFormat.Rgb332; public int? MaxConcurrentViewers { get; set; } = 3; public bool AllowKeyboardInput { get; set; } = true; diff --git a/samples/AspNetCoreWebConsole/appsettings.json b/samples/AspNetCoreWebConsole/appsettings.json index a83e9aa..9c15de1 100644 --- a/samples/AspNetCoreWebConsole/appsettings.json +++ b/samples/AspNetCoreWebConsole/appsettings.json @@ -15,8 +15,8 @@ "InputLeaseMinutes": 5, "MaxWidth": 1024, "MaxHeight": 768, - "MaxFramesPerSecond": 5, - "MaxBytesPerSecond": 500000, + "MaxFramesPerSecond": 8, + "MaxBytesPerSecond": 900000, "MaxColorDepth": "Rgb332", "MaxConcurrentViewers": 3, "AllowKeyboardInput": true, diff --git a/samples/ConsoleDiagnostics/Program.cs b/samples/ConsoleDiagnostics/Program.cs index 6516cb0..5800583 100644 --- a/samples/ConsoleDiagnostics/Program.cs +++ b/samples/ConsoleDiagnostics/Program.cs @@ -2,6 +2,7 @@ var vmSelector = args.FirstOrDefault(arg => !arg.StartsWith("--", StringComparison.OrdinalIgnoreCase)); var sendInput = args.Any(arg => string.Equals(arg, "--send-input", StringComparison.OrdinalIgnoreCase)); +var performanceSeconds = ParsePerformanceSeconds(args); using var client = new HyperVConsoleClient(); var vms = client.GetVirtualMachines(); @@ -109,6 +110,12 @@ } }); +if (performanceSeconds > 0) +{ + Run("Performance Balanced", () => MeasurePerformance(session, ConsoleStreamPreset.Balanced, performanceSeconds)); + Run("Performance Latency", () => MeasurePerformance(session, ConsoleStreamPreset.Latency, performanceSeconds)); +} + Run("Mouse move", () => { if (!currentCapabilities.CanSendMouseInputNow) @@ -117,6 +124,12 @@ return; } + if (!sendInput) + { + Console.WriteLine(" SKIP: pass --send-input to move the guest mouse."); + return; + } + var frameSize = session.CaptureFrame(new ConsoleFrameOptions()); var moved = session.TrySendMouseMove(frameSize.Width / 2, frameSize.Height / 2); Expect(moved, "TrySendMouseMove returned false"); @@ -186,3 +199,72 @@ static void Expect(bool condition, string message) throw new InvalidOperationException(message); } } + +static int ParsePerformanceSeconds(IEnumerable arguments) +{ + const string prefix = "--performance-seconds="; + var argument = arguments.FirstOrDefault(arg => arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (argument == null) + { + return 0; + } + + int seconds; + if (!int.TryParse(argument.Substring(prefix.Length), out seconds) || seconds < 1 || seconds > 300) + { + throw new ArgumentOutOfRangeException(nameof(arguments), "--performance-seconds must be between 1 and 300."); + } + + return seconds; +} + +static void MeasurePerformance(IHyperVConsoleSession session, ConsoleStreamPreset preset, int seconds) +{ + var options = ConsoleFrameStreamOptions.CreatePreset(preset); + options.UseAdaptiveFrameRate = false; + options.FramesPerSecond = options.ActiveFramesPerSecond; + var frames = new List(); + var timer = System.Diagnostics.Stopwatch.StartNew(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(seconds)); + try + { + session.StreamFramesAsync( + options, + (frame, _) => + { + frames.Add(frame); + return Task.CompletedTask; + }, + cancellation.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + + timer.Stop(); + Expect(frames.Count > 0, "no performance frames received"); + var elapsedSeconds = timer.Elapsed.TotalSeconds; + var payloadBytes = frames.Sum(frame => frame.PayloadBytes); + Console.WriteLine($" Requested: {options.Width}x{options.Height} {options.PixelFormat} at {options.FramesPerSecond:F1} fps"); + Console.WriteLine($" Delivered: {frames.Count / elapsedSeconds:F2} fps, {payloadBytes / elapsedSeconds:F0} payload bytes/s"); + Console.WriteLine($" Capture ms p50/p95: {Percentile(frames.Select(frame => frame.CaptureDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CaptureDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" CIM lock wait ms p50/p95: {Percentile(frames.Select(frame => frame.CaptureLockWaitMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CaptureLockWaitMilliseconds), 0.95):F2}"); + Console.WriteLine($" CIM query ms p50/p95: {Percentile(frames.Select(frame => frame.CimQueryDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CimQueryDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" CIM association ms p50/p95: {Percentile(frames.Select(frame => frame.CimAssociationDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CimAssociationDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" CIM metadata ms p50/p95: {Percentile(frames.Select(frame => frame.CimMetadataDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CimMetadataDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" CIM invoke ms p50/p95: {Percentile(frames.Select(frame => frame.CimInvokeDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.CimInvokeDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" Pixel processing ms p50/p95: {Percentile(frames.Select(frame => frame.ProcessingDurationMilliseconds), 0.50):F2}/{Percentile(frames.Select(frame => frame.ProcessingDurationMilliseconds), 0.95):F2}"); + Console.WriteLine($" Raw captures dropped before processing: {frames.Sum(frame => frame.DroppedCaptureFrames)}"); +} + +static double Percentile(IEnumerable values, double percentile) +{ + var ordered = values.OrderBy(value => value).ToArray(); + if (ordered.Length == 0) + { + return 0; + } + + var index = (int)Math.Ceiling(percentile * ordered.Length) - 1; + return ordered[Math.Clamp(index, 0, ordered.Length - 1)]; +} diff --git a/src/HyperVConsoleKit/CimManagement.cs b/src/HyperVConsoleKit/CimManagement.cs index 40052c2..d6ef130 100644 --- a/src/HyperVConsoleKit/CimManagement.cs +++ b/src/HyperVConsoleKit/CimManagement.cs @@ -15,6 +15,8 @@ internal class ManagementScope : IDisposable { private readonly ConcurrentDictionary _references = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _methodMetadata = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private CimSession _session; public ManagementScope(string namespacePath) @@ -95,10 +97,7 @@ internal virtual bool HasMethod(CimInstance instance, string methodName) { try { - using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName)) - { - return cimClass.CimClassMethods[methodName] != null; - } + return GetMethodMetadata(instance, methodName).Exists; } catch (CimException ex) { @@ -113,45 +112,42 @@ internal virtual ManagementBaseObject Invoke( { try { - using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName)) + var metadata = GetMethodMetadata(instance, methodName); + if (!metadata.Exists) { - var declaration = cimClass.CimClassMethods[methodName]; - if (declaration == null) - { - throw new ManagementException( - "CIM class " + instance.CimSystemProperties.ClassName + - " does not expose method " + methodName + "."); - } + throw new ManagementException( + "CIM class " + instance.CimSystemProperties.ClassName + + " does not expose method " + methodName + "."); + } - using (var parameters = new CimMethodParametersCollection()) + using (var parameters = new CimMethodParametersCollection()) + { + if (inParameters != null) { - if (inParameters != null) + foreach (var pair in inParameters.Values) { - foreach (var pair in inParameters.Values) + CimType parameterType; + if (!metadata.ParameterTypes.TryGetValue(pair.Key, out parameterType)) { - var parameterDeclaration = declaration.Parameters[pair.Key]; - if (parameterDeclaration == null) - { - throw new ManagementException( - "CIM method " + methodName + - " has no input parameter named " + pair.Key + "."); - } - - var value = pair.Value is ManagementObject managementObject - ? (object)managementObject.Instance - : pair.Value; - parameters.Add(CimMethodParameter.Create( - pair.Key, - value, - parameterDeclaration.CimType, - CimFlags.In)); + throw new ManagementException( + "CIM method " + methodName + + " has no input parameter named " + pair.Key + "."); } - } - return new ManagementBaseObject( - this, - Session.InvokeMethod(NamespacePath, instance, methodName, parameters)); + var value = pair.Value is ManagementObject managementObject + ? (object)managementObject.Instance + : pair.Value; + parameters.Add(CimMethodParameter.Create( + pair.Key, + value, + parameterType, + CimFlags.In)); + } } + + return new ManagementBaseObject( + this, + Session.InvokeMethod(NamespacePath, instance, methodName, parameters)); } } catch (CimException ex) @@ -172,6 +168,33 @@ internal virtual CimInstance Refresh(CimInstance instance) } } + private CimMethodMetadata GetMethodMetadata(CimInstance instance, string methodName) + { + var className = instance.CimSystemProperties.ClassName; + var cacheKey = className + "\0" + methodName; + return _methodMetadata.GetOrAdd(cacheKey, _ => LoadMethodMetadata(className, methodName)); + } + + internal virtual CimMethodMetadata LoadMethodMetadata(string className, string methodName) + { + using (var cimClass = Session.GetClass(NamespacePath, className)) + { + var declaration = cimClass.CimClassMethods[methodName]; + if (declaration == null) + { + return CimMethodMetadata.Missing; + } + + var parameterTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in declaration.Parameters) + { + parameterTypes[parameter.Name] = parameter.CimType; + } + + return new CimMethodMetadata(true, parameterTypes); + } + } + internal void RegisterReference(CimInstance instance) { var path = instance?.CimSystemProperties?.Path; @@ -251,9 +274,25 @@ public virtual void Dispose() } _references.Clear(); + _methodMetadata.Clear(); } } + internal sealed class CimMethodMetadata + { + public static readonly CimMethodMetadata Missing = + new CimMethodMetadata(false, new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public CimMethodMetadata(bool exists, IReadOnlyDictionary parameterTypes) + { + Exists = exists; + ParameterTypes = parameterTypes; + } + + public bool Exists { get; } + public IReadOnlyDictionary ParameterTypes { get; } + } + internal sealed class ObjectQuery { public ObjectQuery(string queryString) diff --git a/src/HyperVConsoleKit/HyperVConsoleClient.cs b/src/HyperVConsoleKit/HyperVConsoleClient.cs index 1d90263..82a4d0a 100644 --- a/src/HyperVConsoleKit/HyperVConsoleClient.cs +++ b/src/HyperVConsoleKit/HyperVConsoleClient.cs @@ -23,6 +23,7 @@ public sealed class HyperVConsoleClient : IDisposable private readonly ManagementScope _scope; private readonly object _wmiLock = new object(); private readonly ConcurrentDictionary _virtualMachineLocks = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _virtualMachineActivitySignals = new ConcurrentDictionary(); private readonly HyperVConsolePolicy _policy; private int _disposeState; private static readonly TimeSpan DefaultJobTimeout = TimeSpan.FromMinutes(5); @@ -167,7 +168,12 @@ public IHyperVConsoleSession OpenConsole(Guid virtualMachineId, HyperVConsoleOpe var sessionPolicy = (options.Policy ?? _policy).Clone(); sessionPolicy.Validate(); - var session = new HyperVConsoleSession(_scope, GetVirtualMachineLock(virtualMachineId), virtualMachineId, sessionPolicy); + var session = new HyperVConsoleSession( + _scope, + GetVirtualMachineLock(virtualMachineId), + GetVirtualMachineActivitySignal(virtualMachineId), + virtualMachineId, + sessionPolicy); session.Activity += OnSessionActivity; session.NotifyOpened(); return session; @@ -646,6 +652,11 @@ private object GetVirtualMachineLock(Guid virtualMachineId) return _virtualMachineLocks.GetOrAdd(virtualMachineId, _ => new object()); } + private ConsoleActivitySignal GetVirtualMachineActivitySignal(Guid virtualMachineId) + { + return _virtualMachineActivitySignals.GetOrAdd(virtualMachineId, _ => new ConsoleActivitySignal()); + } + private void ExecuteAuditedAction(Guid virtualMachineId, HyperVConsoleAuditAction action, string successMessage, Action operation) { try @@ -819,19 +830,88 @@ public ConsoleFrameSize(int width, int height, int score) public int Score { get; private set; } } + internal sealed class ConsoleActivitySignal + { + private readonly object _lock = new object(); + private long _version; + private TaskCompletionSource _nextPulse = CreateCompletionSource(); + + public long Version + { + get + { + lock (_lock) + { + return _version; + } + } + } + + public void Pulse() + { + TaskCompletionSource completion; + long version; + lock (_lock) + { + version = ++_version; + completion = _nextPulse; + _nextPulse = CreateCompletionSource(); + } + + completion.TrySetResult(version); + } + + public async Task WaitForPulseAsync(long observedVersion, int delayMilliseconds, CancellationToken cancellationToken) + { + Task pulseTask; + lock (_lock) + { + if (_version != observedVersion) + { + return true; + } + + pulseTask = _nextPulse.Task; + } + + if (delayMilliseconds <= 0) + { + return false; + } + + var delayTask = Task.Delay(delayMilliseconds, cancellationToken); + var completed = await Task.WhenAny(pulseTask, delayTask).ConfigureAwait(false); + if (completed == pulseTask) + { + cancellationToken.ThrowIfCancellationRequested(); + return true; + } + + await delayTask.ConfigureAwait(false); + return false; + } + + private static TaskCompletionSource CreateCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + internal sealed class HyperVConsoleSession : IHyperVConsoleSession { private readonly ManagementScope _scope; private readonly object _wmiLock; + private readonly ConsoleActivitySignal _activitySignal; private readonly Guid _virtualMachineId; private readonly HyperVConsolePolicy _policy; private int _disposeState; public event EventHandler Activity; - public HyperVConsoleSession(ManagementScope scope, object wmiLock, Guid virtualMachineId, HyperVConsolePolicy policy) + public HyperVConsoleSession(ManagementScope scope, object wmiLock, ConsoleActivitySignal activitySignal, Guid virtualMachineId, HyperVConsolePolicy policy) { _scope = scope; _wmiLock = wmiLock; + _activitySignal = activitySignal; _virtualMachineId = virtualMachineId; _policy = (policy ?? new HyperVConsolePolicy()).Clone(); lock (_wmiLock) @@ -865,44 +945,69 @@ public ConsoleFrame CaptureFrame(ConsoleFrameOptions options) _policy.ApplyTo(options); ValidateFrameOptions(options); + var lockTimer = Stopwatch.StartNew(); lock (_wmiLock) { + var lockWaitMilliseconds = lockTimer.Elapsed.TotalMilliseconds; + var captureTimer = Stopwatch.StartNew(); + var operationTimer = Stopwatch.StartNew(); using (var vm = GetVirtualMachineObject()) - using (var settings = GetFirstRelatedObject(vm, "Msvm_VirtualSystemSettingData", "Msvm_SettingsDefineState", "SettingData", "ManagedElement")) - using (var service = GetManagementService()) - using (var inParams = service.GetMethodParameters("GetVirtualSystemThumbnailImage")) { - if (settings == null) - { - throw new HyperVConsoleCaptureNotSupportedException("No Hyper-V virtual system settings object was found for this virtual machine."); - } - - inParams["TargetSystem"] = settings; - inParams["WidthPixels"] = (ushort)options.Width; - inParams["HeightPixels"] = (ushort)options.Height; - - using (var outParams = service.InvokeMethod("GetVirtualSystemThumbnailImage", inParams, null)) + var queryDurationMilliseconds = operationTimer.Elapsed.TotalMilliseconds; + operationTimer.Restart(); + using (var settings = GetFirstRelatedObject(vm, "Msvm_VirtualSystemSettingData", "Msvm_SettingsDefineState", "SettingData", "ManagedElement")) { - var returnCode = Convert.ToUInt32(outParams["ReturnValue"]); - if (returnCode == WmiReturnCode.NotSupported) + var associationDurationMilliseconds = operationTimer.Elapsed.TotalMilliseconds; + if (settings == null) { - throw new HyperVConsoleCaptureNotSupportedException("Hyper-V console thumbnail capture is not supported for this virtual machine."); + throw new HyperVConsoleCaptureNotSupportedException("No Hyper-V virtual system settings object was found for this virtual machine."); } - HyperVConsoleClient.EnsureCompleted("Msvm_VirtualSystemManagementService", "GetVirtualSystemThumbnailImage", outParams, _scope); - - var rawRgb565 = NormalizeRawRgb565((byte[])outParams["ImageData"], options.Width, options.Height); - var frame = new ConsoleFrame + operationTimer.Restart(); + using (var service = GetManagementService()) { - VirtualMachineId = _virtualMachineId, - CapturedUtc = DateTime.UtcNow, - Width = options.Width, - Height = options.Height, - PixelFormat = ConsoleFramePixelFormat.Rgb565, - RawBytes = rawRgb565 - }; - RaiseActivity(HyperVConsoleAuditAction.FrameCaptured, true, "Frame captured.", frame.RawBytes.Length); - return frame; + queryDurationMilliseconds += operationTimer.Elapsed.TotalMilliseconds; + operationTimer.Restart(); + using (var inParams = service.GetMethodParameters("GetVirtualSystemThumbnailImage")) + { + var metadataDurationMilliseconds = operationTimer.Elapsed.TotalMilliseconds; + inParams["TargetSystem"] = settings; + inParams["WidthPixels"] = (ushort)options.Width; + inParams["HeightPixels"] = (ushort)options.Height; + + operationTimer.Restart(); + using (var outParams = service.InvokeMethod("GetVirtualSystemThumbnailImage", inParams, null)) + { + var returnCode = Convert.ToUInt32(outParams["ReturnValue"]); + if (returnCode == WmiReturnCode.NotSupported) + { + throw new HyperVConsoleCaptureNotSupportedException("Hyper-V console thumbnail capture is not supported for this virtual machine."); + } + + HyperVConsoleClient.EnsureCompleted("Msvm_VirtualSystemManagementService", "GetVirtualSystemThumbnailImage", outParams, _scope); + var invokeDurationMilliseconds = operationTimer.Elapsed.TotalMilliseconds; + + var rawRgb565 = NormalizeRawRgb565((byte[])outParams["ImageData"], options.Width, options.Height); + var frame = new ConsoleFrame + { + VirtualMachineId = _virtualMachineId, + CapturedUtc = DateTime.UtcNow, + Width = options.Width, + Height = options.Height, + PixelFormat = ConsoleFramePixelFormat.Rgb565, + RawBytes = rawRgb565, + CaptureLockWaitMilliseconds = lockWaitMilliseconds, + CaptureDurationMilliseconds = captureTimer.Elapsed.TotalMilliseconds, + CimQueryDurationMilliseconds = queryDurationMilliseconds, + CimAssociationDurationMilliseconds = associationDurationMilliseconds, + CimMetadataDurationMilliseconds = metadataDurationMilliseconds, + CimInvokeDurationMilliseconds = invokeDurationMilliseconds + }; + RaiseActivity(HyperVConsoleAuditAction.FrameCaptured, true, "Frame captured.", frame.RawBytes.Length); + return frame; + } + } + } } } } @@ -939,22 +1044,26 @@ public async Task StreamFramesAsync(ConsoleFrameStreamOptions options, Func= options.FullFrameInterval; var streamFrame = BuildStreamFrame(captured, converted, previousPayload, options, forceKeyFrame, sequenceNumber + 1); var changedBytes = streamFrame.UpdateKind == ConsoleFrameUpdateKind.FullFrame ? streamFrame.PayloadBytes : streamFrame.Tiles.Sum(t => (long)t.RawBytes.Length); - var targetFps = !options.UseAdaptiveFrameRate - ? options.FramesPerSecond - : changedBytes < options.ActiveChangeThresholdBytes - ? options.IdleFramesPerSecond - : options.ActiveFramesPerSecond; + streamFrame.ProcessingDurationMilliseconds = processingTimer.Elapsed.TotalMilliseconds; + var targetFps = SelectTargetFramesPerSecond(options, changedBytes, inputBoost); streamFrame.TargetFramesPerSecond = targetFps; @@ -973,7 +1082,7 @@ public async Task StreamFramesAsync(ConsoleFrameStreamOptions options, Func 0) { - await Task.Delay(remainingMs, cancellationToken).ConfigureAwait(false); + inputBoostPending = await _activitySignal.WaitForPulseAsync(activityVersion, remainingMs, cancellationToken).ConfigureAwait(false); } else if (!options.DropFramesWhenBehind) { @@ -993,20 +1102,34 @@ private async Task StreamLatestFramesAsync(ConsoleFrameStreamOptions options, Fu Exception producerException = null; var producerDone = false; var signalPending = false; + var latestInputBoost = false; + var droppedCaptureFrames = 0; var captureFramesPerSecond = options.UseAdaptiveFrameRate ? options.ActiveFramesPerSecond : options.FramesPerSecond; var producer = Task.Run(async () => { try { + var lastActivityVersion = _activitySignal.Version; + var inputBoostPending = false; while (!streamToken.IsCancellationRequested) { var timer = Stopwatch.StartNew(); var captured = await CaptureFrameAsync(captureOptions, streamToken).ConfigureAwait(false); + var activityVersion = _activitySignal.Version; + var inputBoost = inputBoostPending || activityVersion != lastActivityVersion; + inputBoostPending = false; + lastActivityVersion = activityVersion; double currentCaptureFramesPerSecond; lock (latestLock) { + if (latestCaptured != null) + { + droppedCaptureFrames++; + } + latestCaptured = captured; + latestInputBoost = latestInputBoost || inputBoost; currentCaptureFramesPerSecond = captureFramesPerSecond; if (!signalPending) { @@ -1020,7 +1143,7 @@ private async Task StreamLatestFramesAsync(ConsoleFrameStreamOptions options, Fu var remainingMs = delayMs - elapsedMs; if (remainingMs > 0) { - await Task.Delay(remainingMs, streamToken).ConfigureAwait(false); + inputBoostPending = await _activitySignal.WaitForPulseAsync(activityVersion, remainingMs, streamToken).ConfigureAwait(false); } } } @@ -1058,10 +1181,16 @@ private async Task StreamLatestFramesAsync(ConsoleFrameStreamOptions options, Fu await signal.WaitAsync(streamToken).ConfigureAwait(false); ConsoleFrame captured; + bool inputBoost; + int droppedFrames; lock (latestLock) { captured = latestCaptured; latestCaptured = null; + inputBoost = latestInputBoost; + latestInputBoost = false; + droppedFrames = droppedCaptureFrames; + droppedCaptureFrames = 0; signalPending = false; if (captured == null && producerDone) { @@ -1074,17 +1203,16 @@ private async Task StreamLatestFramesAsync(ConsoleFrameStreamOptions options, Fu continue; } + var processingTimer = Stopwatch.StartNew(); var converted = PixelCodec.ConvertRgb565(captured.RawBytes, options.PixelFormat); var forceKeyFrame = previousPayload == null || !options.SendChangedTilesOnly || framesSinceKeyFrame >= options.FullFrameInterval; var streamFrame = BuildStreamFrame(captured, converted, previousPayload, options, forceKeyFrame, sequenceNumber + 1); var changedBytes = streamFrame.UpdateKind == ConsoleFrameUpdateKind.FullFrame ? streamFrame.PayloadBytes : streamFrame.Tiles.Sum(t => (long)t.RawBytes.Length); - var targetFps = !options.UseAdaptiveFrameRate - ? options.FramesPerSecond - : changedBytes < options.ActiveChangeThresholdBytes - ? options.IdleFramesPerSecond - : options.ActiveFramesPerSecond; + streamFrame.ProcessingDurationMilliseconds = processingTimer.Elapsed.TotalMilliseconds; + streamFrame.DroppedCaptureFrames = droppedFrames; + var targetFps = SelectTargetFramesPerSecond(options, changedBytes, inputBoost); streamFrame.TargetFramesPerSecond = targetFps; lock (latestLock) @@ -1410,23 +1538,7 @@ public bool TrySendMouseMove(int x, int y) return false; } - if (!HasMethod(mouse, "SetAbsolutePosition")) - { - RaiseActivity(HyperVConsoleAuditAction.MouseMoved, false, "Synthetic mouse does not expose SetAbsolutePosition.", null); - return false; - } - - using (var inParams = mouse.GetMethodParameters("SetAbsolutePosition")) - { - inParams["HorizontalPosition"] = x; - inParams["VerticalPosition"] = y; - using (var outParams = mouse.InvokeMethod("SetAbsolutePosition", inParams, null)) - { - var success = IsSuccessfulMouseReturn(outParams); - RaiseActivity(HyperVConsoleAuditAction.MouseMoved, success, "Mouse moved to " + x + "," + y + ".", null); - return success; - } - } + return TryMoveMouse(mouse, x, y); } } catch (ManagementException ex) @@ -1446,15 +1558,15 @@ public bool TrySendMouseClick(int x, int y, MouseButton button) { ThrowIfDisposed(); _policy.EnsureMouseAllowed(); + if (x < 0 || y < 0) + { + return false; + } + lock (_wmiLock) { try { - if (!TrySendMouseMove(x, y)) - { - return false; - } - using (var mouse = GetMouseObject()) { if (mouse == null) @@ -1462,21 +1574,12 @@ public bool TrySendMouseClick(int x, int y, MouseButton button) return false; } - if (!HasMethod(mouse, "ClickButton")) + if (!TryMoveMouse(mouse, x, y)) { return false; } - using (var inParams = mouse.GetMethodParameters("ClickButton")) - { - inParams["ButtonIndex"] = (uint)button; - using (var outParams = mouse.InvokeMethod("ClickButton", inParams, null)) - { - var success = IsSuccessfulMouseReturn(outParams); - RaiseActivity(HyperVConsoleAuditAction.MouseClicked, success, "Mouse click: " + button, null); - return success; - } - } + return TryClickMouse(mouse, button); } } catch (ManagementException) @@ -1494,17 +1597,32 @@ public Task TrySendMouseClickAsync(int x, int y, MouseButton button, Cance public bool TrySendMouseDoubleClick(int x, int y, MouseButton button) { _policy.EnsureMouseAllowed(); + if (x < 0 || y < 0) + { + return false; + } + lock (_wmiLock) { - if (!TrySendMouseClick(x, y, button)) + try + { + using (var mouse = GetMouseObject()) + { + if (mouse == null || !TryMoveMouse(mouse, x, y) || !TryClickMouse(mouse, button)) + { + return false; + } + + System.Threading.Thread.Sleep(100); + var success = TryClickMouse(mouse, button); + RaiseActivity(HyperVConsoleAuditAction.MouseDoubleClicked, success, "Mouse double click: " + button, null); + return success; + } + } + catch (ManagementException) { return false; } - - System.Threading.Thread.Sleep(100); - var success = TrySendMouseClick(x, y, button); - RaiseActivity(HyperVConsoleAuditAction.MouseDoubleClicked, success, "Mouse double click: " + button, null); - return success; } } @@ -1550,6 +1668,7 @@ private void InvokeKeyboardMethod(string methodName, string parameterName, objec } HyperVConsoleClient.EnsureCompleted("Msvm_Keyboard", methodName, outParams, _scope); + _activitySignal.Pulse(); } } } @@ -1558,11 +1677,58 @@ private void InvokeKeyboardMethod(string methodName, string parameterName, objec private ManagementObject GetMouseObject() { - lock (_wmiLock) + using (var vm = GetVirtualMachineObject()) { - using (var vm = GetVirtualMachineObject()) + return GetFirstRelatedObject(vm, "Msvm_SyntheticMouse", "Msvm_SystemDevice", "PartComponent", "GroupComponent"); + } + } + + private bool TryMoveMouse(ManagementObject mouse, int x, int y) + { + if (!HasMethod(mouse, "SetAbsolutePosition")) + { + RaiseActivity(HyperVConsoleAuditAction.MouseMoved, false, "Synthetic mouse does not expose SetAbsolutePosition.", null); + return false; + } + + using (var inParams = mouse.GetMethodParameters("SetAbsolutePosition")) + { + inParams["HorizontalPosition"] = x; + inParams["VerticalPosition"] = y; + using (var outParams = mouse.InvokeMethod("SetAbsolutePosition", inParams, null)) + { + var success = IsSuccessfulMouseReturn(outParams); + if (success) + { + _activitySignal.Pulse(); + } + + RaiseActivity(HyperVConsoleAuditAction.MouseMoved, success, "Mouse moved to " + x + "," + y + ".", null); + return success; + } + } + } + + private bool TryClickMouse(ManagementObject mouse, MouseButton button) + { + if (!HasMethod(mouse, "ClickButton")) + { + return false; + } + + using (var inParams = mouse.GetMethodParameters("ClickButton")) + { + inParams["ButtonIndex"] = (uint)button; + using (var outParams = mouse.InvokeMethod("ClickButton", inParams, null)) { - return GetFirstRelatedObject(vm, "Msvm_SyntheticMouse", "Msvm_SystemDevice", "PartComponent", "GroupComponent"); + var success = IsSuccessfulMouseReturn(outParams); + if (success) + { + _activitySignal.Pulse(); + } + + RaiseActivity(HyperVConsoleAuditAction.MouseClicked, success, "Mouse click: " + button, null); + return success; } } } @@ -1769,6 +1935,23 @@ private static void ValidateStreamOptions(ConsoleFrameStreamOptions options) } } + internal static double SelectTargetFramesPerSecond(ConsoleFrameStreamOptions options, long changedBytes, bool inputBoost) + { + if (inputBoost) + { + return options.ActiveFramesPerSecond; + } + + if (!options.UseAdaptiveFrameRate) + { + return options.FramesPerSecond; + } + + return changedBytes < options.ActiveChangeThresholdBytes + ? options.IdleFramesPerSecond + : options.ActiveFramesPerSecond; + } + private ConsoleFrame BuildStreamFrame(ConsoleFrame captured, byte[] converted, byte[] previousPayload, ConsoleFrameStreamOptions options, bool forceKeyFrame, long sequenceNumber) { if (forceKeyFrame) @@ -1785,6 +1968,7 @@ private ConsoleFrame BuildStreamFrame(ConsoleFrame captured, byte[] converted, b BytesPerPixelNumerator = PixelCodec.GetBytesPerPixelNumerator(options.PixelFormat), BytesPerPixelDenominator = PixelCodec.GetBytesPerPixelDenominator(options.PixelFormat), RawBytes = converted, + CompleteFrameBytes = converted, Tiles = new ConsoleFrameTile[0], IsKeyFrame = true, PayloadBytes = converted.Length @@ -1805,6 +1989,7 @@ private ConsoleFrame BuildStreamFrame(ConsoleFrame captured, byte[] converted, b BytesPerPixelNumerator = PixelCodec.GetBytesPerPixelNumerator(options.PixelFormat), BytesPerPixelDenominator = PixelCodec.GetBytesPerPixelDenominator(options.PixelFormat), RawBytes = null, + CompleteFrameBytes = converted, Tiles = tiles, IsKeyFrame = false, PayloadBytes = payloadBytes @@ -1947,6 +2132,65 @@ public static IReadOnlyList GetChangedTiles(byte[] previous, b return tiles; } + public static void ApplyTiles(byte[] target, int frameWidth, int frameHeight, ConsoleFramePixelFormat format, IReadOnlyList tiles) + { + if (target == null) + { + throw new ArgumentNullException("target"); + } + + if (tiles == null) + { + throw new ArgumentNullException("tiles"); + } + + var expectedLength = GetPackedLength(checked(frameWidth * frameHeight), format); + if (target.Length != expectedLength) + { + throw new ArgumentException("The target payload does not match the supplied frame dimensions and pixel format.", "target"); + } + + var numerator = GetBytesPerPixelNumerator(format); + var denominator = GetBytesPerPixelDenominator(format); + foreach (var tile in tiles) + { + if (tile == null || tile.RawBytes == null || tile.X < 0 || tile.Y < 0 || tile.Width < 1 || tile.Height < 1 || + tile.X + tile.Width > frameWidth || tile.Y + tile.Height > frameHeight) + { + throw new ArgumentException("A changed tile was outside the target frame or did not contain a payload.", "tiles"); + } + + var expectedTileLength = GetPackedLength(checked(tile.Width * tile.Height), format); + if (tile.RawBytes.Length != expectedTileLength) + { + throw new ArgumentException("A changed tile payload did not match its dimensions and pixel format.", "tiles"); + } + + if (denominator == 1) + { + var rowLength = tile.Width * numerator; + for (var row = 0; row < tile.Height; row++) + { + var sourceOffset = row * rowLength; + var targetOffset = ((tile.Y + row) * frameWidth + tile.X) * numerator; + Buffer.BlockCopy(tile.RawBytes, sourceOffset, target, targetOffset, rowLength); + } + + continue; + } + + var sourcePixel = 0; + for (var row = 0; row < tile.Height; row++) + { + for (var column = 0; column < tile.Width; column++) + { + var targetPixel = (tile.Y + row) * frameWidth + tile.X + column; + SetPixelValue(target, targetPixel, format, GetPixelValue(tile.RawBytes, sourcePixel++, format)); + } + } + } + } + public static int GetBytesPerPixelNumerator(ConsoleFramePixelFormat format) { switch (format) diff --git a/src/HyperVConsoleKit/HyperVConsoleFrameHub.cs b/src/HyperVConsoleKit/HyperVConsoleFrameHub.cs index 4cd25d7..b8c5871 100644 --- a/src/HyperVConsoleKit/HyperVConsoleFrameHub.cs +++ b/src/HyperVConsoleKit/HyperVConsoleFrameHub.cs @@ -20,7 +20,7 @@ public sealed class HyperVConsoleFrameHub private readonly int? _maxViewers; private readonly object _lock = new object(); private readonly List _subscribers = new List(); - private ConsoleFrame _latestKeyFrame; + private ConsoleFrame _currentFrame; private HubState _state; private Exception _failure; @@ -140,9 +140,9 @@ public async Task AddViewerAsync(Func onF } _subscribers.Add(subscriber); - if (_latestKeyFrame != null) + if (_currentFrame != null) { - subscriber.Publish(_latestKeyFrame); + subscriber.Publish(CloneFullFrame(_currentFrame, true), null); } } @@ -166,22 +166,137 @@ private Task PublishAsync(ConsoleFrame frame, CancellationToken cancellationToke FrameHubSubscriber[] subscribers; lock (_lock) { - if (frame != null && frame.IsKeyFrame) - { - _latestKeyFrame = frame; - } - + UpdateCurrentFrame(frame); subscribers = _subscribers.ToArray(); } + var recoveryLock = new object(); + ConsoleFrame recoveryFrame = null; + Func getRecoveryFrame = () => + { + lock (recoveryLock) + { + if (recoveryFrame == null) + { + lock (_lock) + { + recoveryFrame = _currentFrame == null ? null : CloneFullFrame(_currentFrame, true); + } + } + + return recoveryFrame; + } + }; + foreach (var subscriber in subscribers) { - subscriber.Publish(frame); + subscriber.Publish(frame, getRecoveryFrame); } return Task.FromResult(0); } + private void UpdateCurrentFrame(ConsoleFrame frame) + { + if (frame == null) + { + return; + } + + if (frame.UpdateKind == ConsoleFrameUpdateKind.FullFrame) + { + if (frame.RawBytes == null) + { + throw new HyperVConsoleException("A full console frame did not contain a pixel payload."); + } + + _currentFrame = CloneFullFrame(frame, false); + return; + } + + if (_currentFrame == null) + { + throw new HyperVConsoleException("A changed-tile frame was received before a full console frame."); + } + + if (_currentFrame.Width != frame.Width || + _currentFrame.Height != frame.Height || + _currentFrame.PixelFormat != frame.PixelFormat) + { + throw new HyperVConsoleException("A changed-tile frame did not match the current console frame shape."); + } + + if (frame.CompleteFrameBytes != null) + { + var expectedLength = ((long)frame.Width * frame.Height * frame.BytesPerPixelNumerator + frame.BytesPerPixelDenominator - 1) / + frame.BytesPerPixelDenominator; + if (frame.CompleteFrameBytes.LongLength != expectedLength) + { + throw new HyperVConsoleException("A complete changed-tile snapshot did not match the current console frame shape."); + } + + _currentFrame.RawBytes = frame.CompleteFrameBytes; + } + else + { + PixelCodec.ApplyTiles( + _currentFrame.RawBytes, + frame.Width, + frame.Height, + frame.PixelFormat, + frame.Tiles ?? new ConsoleFrameTile[0]); + } + + _currentFrame.VirtualMachineId = frame.VirtualMachineId; + _currentFrame.CapturedUtc = frame.CapturedUtc; + _currentFrame.SequenceNumber = frame.SequenceNumber; + _currentFrame.TargetFramesPerSecond = frame.TargetFramesPerSecond; + _currentFrame.CaptureLockWaitMilliseconds = frame.CaptureLockWaitMilliseconds; + _currentFrame.CaptureDurationMilliseconds = frame.CaptureDurationMilliseconds; + _currentFrame.CimQueryDurationMilliseconds = frame.CimQueryDurationMilliseconds; + _currentFrame.CimAssociationDurationMilliseconds = frame.CimAssociationDurationMilliseconds; + _currentFrame.CimMetadataDurationMilliseconds = frame.CimMetadataDurationMilliseconds; + _currentFrame.CimInvokeDurationMilliseconds = frame.CimInvokeDurationMilliseconds; + _currentFrame.ProcessingDurationMilliseconds = frame.ProcessingDurationMilliseconds; + _currentFrame.DroppedCaptureFrames = frame.DroppedCaptureFrames; + } + + private static ConsoleFrame CloneFullFrame(ConsoleFrame frame, bool isRecoveryKeyFrame) + { + var payload = frame.RawBytes == null ? new byte[0] : new byte[frame.RawBytes.Length]; + if (payload.Length > 0) + { + Buffer.BlockCopy(frame.RawBytes, 0, payload, 0, payload.Length); + } + + return new ConsoleFrame + { + VirtualMachineId = frame.VirtualMachineId, + CapturedUtc = frame.CapturedUtc, + SequenceNumber = frame.SequenceNumber, + Width = frame.Width, + Height = frame.Height, + PixelFormat = frame.PixelFormat, + UpdateKind = ConsoleFrameUpdateKind.FullFrame, + BytesPerPixelNumerator = frame.BytesPerPixelNumerator, + BytesPerPixelDenominator = frame.BytesPerPixelDenominator, + RawBytes = payload, + Tiles = new ConsoleFrameTile[0], + IsKeyFrame = true, + PayloadBytes = payload.Length, + TargetFramesPerSecond = frame.TargetFramesPerSecond, + CaptureLockWaitMilliseconds = frame.CaptureLockWaitMilliseconds, + CaptureDurationMilliseconds = frame.CaptureDurationMilliseconds, + CimQueryDurationMilliseconds = frame.CimQueryDurationMilliseconds, + CimAssociationDurationMilliseconds = frame.CimAssociationDurationMilliseconds, + CimMetadataDurationMilliseconds = frame.CimMetadataDurationMilliseconds, + CimInvokeDurationMilliseconds = frame.CimInvokeDurationMilliseconds, + ProcessingDurationMilliseconds = frame.ProcessingDurationMilliseconds, + DroppedCaptureFrames = frame.DroppedCaptureFrames, + IsRecoveryKeyFrame = isRecoveryKeyFrame + }; + } + private enum HubState { Created, @@ -207,7 +322,7 @@ public FrameHubSubscriber(Func onFrame) _onFrame = onFrame; } - public void Publish(ConsoleFrame frame) + public void Publish(ConsoleFrame frame, Func getRecoveryFrame) { lock (_lock) { @@ -223,7 +338,15 @@ public void Publish(ConsoleFrame frame) } else { - _latest = frame; + if (_latest != null) + { + _pendingKeyFrame = getRecoveryFrame == null ? null : getRecoveryFrame(); + _latest = null; + } + else + { + _latest = frame; + } } if (!_signalPending) diff --git a/src/HyperVConsoleKit/HyperVConsoleKit.csproj b/src/HyperVConsoleKit/HyperVConsoleKit.csproj index 835df20..b8b2779 100644 --- a/src/HyperVConsoleKit/HyperVConsoleKit.csproj +++ b/src/HyperVConsoleKit/HyperVConsoleKit.csproj @@ -5,7 +5,7 @@ HyperVConsoleKit HyperVConsoleKit HyperVConsoleKit - 0.6.0-preview.1 + 0.6.0-preview.2 HyperVConsoleKit Host-side emergency Hyper-V VM console access toolkit. Hyper-V;WMI;console;virtualization diff --git a/src/HyperVConsoleKit/Models.cs b/src/HyperVConsoleKit/Models.cs index 864f51e..3f9bb9f 100644 --- a/src/HyperVConsoleKit/Models.cs +++ b/src/HyperVConsoleKit/Models.cs @@ -705,6 +705,43 @@ public sealed class ConsoleFrame public bool IsKeyFrame { get; set; } public long PayloadBytes { get; set; } public double TargetFramesPerSecond { get; set; } + /// + /// Time spent waiting to enter the per-VM CIM serialization gate for this capture. + /// + public double CaptureLockWaitMilliseconds { get; set; } + /// + /// Time spent resolving and invoking the Hyper-V thumbnail capture operation. + /// + public double CaptureDurationMilliseconds { get; set; } + /// + /// Time spent querying the VM and host management-service CIM instances. + /// + public double CimQueryDurationMilliseconds { get; set; } + /// + /// Time spent resolving the VM settings association used for thumbnail capture. + /// + public double CimAssociationDurationMilliseconds { get; set; } + /// + /// Time spent resolving CIM method metadata. Warm calls normally use the metadata cache. + /// + public double CimMetadataDurationMilliseconds { get; set; } + /// + /// Time spent invoking and completing the Hyper-V thumbnail method. + /// + public double CimInvokeDurationMilliseconds { get; set; } + /// + /// Time spent converting and comparing pixels before this streamed frame was published. + /// + public double ProcessingDurationMilliseconds { get; set; } + /// + /// Number of raw captures replaced by a newer capture before stream processing. + /// + public int DroppedCaptureFrames { get; set; } + /// + /// True when a frame hub synthesized this full frame to repair a viewer's incremental baseline. + /// + public bool IsRecoveryKeyFrame { get; set; } + internal byte[] CompleteFrameBytes { get; set; } } /// diff --git a/tests/HyperVConsoleKit.Tests/CimMethodMetadataCacheTests.cs b/tests/HyperVConsoleKit.Tests/CimMethodMetadataCacheTests.cs new file mode 100644 index 0000000..bf7846f --- /dev/null +++ b/tests/HyperVConsoleKit.Tests/CimMethodMetadataCacheTests.cs @@ -0,0 +1,43 @@ +using HyperVConsoleKit; +using Microsoft.Management.Infrastructure; + +namespace HyperVConsoleKit.Tests; + +public sealed class CimMethodMetadataCacheTests +{ + [Fact] + public void MethodMetadataIsLoadedOncePerClassAndMethod() + { + using var scope = new CountingManagementScope(); + using var keyboard = new CimInstance("Msvm_Keyboard", @"root\virtualization\v2"); + + Assert.True(scope.HasMethod(keyboard, "TypeKey")); + Assert.True(scope.HasMethod(keyboard, "TypeKey")); + + Assert.Equal(1, scope.LoadCount); + } + + private sealed class CountingManagementScope : ManagementScope + { + public CountingManagementScope() : base(@"root\virtualization\v2") + { + } + + public int LoadCount { get; private set; } + + public override void Connect() + { + } + + internal override CimMethodMetadata LoadMethodMetadata(string className, string methodName) + { + LoadCount++; + return new CimMethodMetadata( + true, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["keyCode"] = CimType.UInt32 + }); + } + } +} diff --git a/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs index 4857174..9c1bf66 100644 --- a/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs +++ b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs @@ -119,6 +119,20 @@ public void ManagedAssemblyHasNoSystemManagementReference() assembly => string.Equals(assembly.Name, "System.Management", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void MouseClickResolvesSyntheticMouseOnceForMoveAndClick() + { + var vmId = Guid.NewGuid(); + using var scope = new FakeManagementScope(vmId, 2, false, true) { IncludeMouse = true }; + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy()); + using var session = client.OpenConsole(vmId); + + Assert.True(session.TrySendMouseClick(10, 20, MouseButton.Left)); + + Assert.Equal(1, scope.MouseAssociationQueries); + Assert.Equal(2, scope.MouseInvocations); + } + [Fact] public void MissingProviderNamespaceHasSpecificException() { @@ -154,6 +168,9 @@ public FakeManagementScope( public bool JobWasRefreshed { get; private set; } public ushort JobFinalState { get; set; } = 7; public uint JobErrorCode { get; set; } + public bool IncludeMouse { get; set; } + public int MouseAssociationQueries { get; private set; } + public int MouseInvocations { get; private set; } public override void Connect() { @@ -220,7 +237,10 @@ internal override IEnumerable GetRelated( return new[] { Instance("Msvm_Keyboard") }; case "Msvm_SyntheticMouse": - return Array.Empty(); + MouseAssociationQueries++; + return IncludeMouse + ? new[] { Instance("Msvm_SyntheticMouse") } + : Array.Empty(); default: return Array.Empty(); @@ -232,7 +252,9 @@ internal override bool HasMethod(CimInstance instance, string methodName) return instance.CimSystemProperties.ClassName == "Msvm_ComputerSystem" ? methodName == "RequestStateChange" : instance.CimSystemProperties.ClassName == "Msvm_Keyboard" - && (methodName == "TypeKey" || methodName == "PressKey" || methodName == "ReleaseKey"); + ? methodName == "TypeKey" || methodName == "PressKey" || methodName == "ReleaseKey" + : instance.CimSystemProperties.ClassName == "Msvm_SyntheticMouse" + && (methodName == "SetAbsolutePosition" || methodName == "ClickButton"); } internal override ManagementBaseObject Invoke( @@ -242,6 +264,10 @@ internal override ManagementBaseObject Invoke( { LastInvokedMethod = methodName; LastInputParameters = inParameters.Values.ToDictionary(pair => pair.Key, pair => pair.Value); + if (instance.CimSystemProperties.ClassName == "Msvm_SyntheticMouse") + { + MouseInvocations++; + } var result = new ManagementBaseObject(); result["ReturnValue"] = ReturnAsynchronousJob ? (uint)4096 : 0u; diff --git a/tests/HyperVConsoleKit.Tests/FrameHubTests.cs b/tests/HyperVConsoleKit.Tests/FrameHubTests.cs index c05770d..1bd6583 100644 --- a/tests/HyperVConsoleKit.Tests/FrameHubTests.cs +++ b/tests/HyperVConsoleKit.Tests/FrameHubTests.cs @@ -22,7 +22,7 @@ public async Task AddViewerAsyncCompletesWhenHubProducerStops() } [Fact] - public async Task LateViewerReceivesKeyFrameBeforeAnyDelta() + public async Task LateViewerReceivesCurrentKeyFrameBeforeAnyDelta() { var session = new ControlledSession(); var hub = new HyperVConsoleFrameHub(session, new ConsoleFrameStreamOptions()); @@ -43,10 +43,57 @@ public async Task LateViewerReceivesKeyFrameBeforeAnyDelta() await viewerTask; Assert.NotNull(received); Assert.True(received!.IsKeyFrame); + Assert.True(received.IsRecoveryKeyFrame); + Assert.Equal(2, received.SequenceNumber); + Assert.Equal(new byte[] { 10, 20 }, received.RawBytes); session.ReleaseProducer.TrySetResult(); await runTask; } + [Fact] + public async Task SlowViewerReceivesCurrentKeyFrameWhenIncrementalUpdateIsDropped() + { + var session = new SlowViewerSession(); + var hub = new HyperVConsoleFrameHub(session, new ConsoleFrameStreamOptions()); + using var producerCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var viewerCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var firstFrameStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var recovered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var received = 0; + + var viewerTask = hub.AddViewerAsync(async (frame, _) => + { + received++; + if (received == 1) + { + firstFrameStarted.TrySetResult(); + await releaseFirstFrame.Task; + return; + } + + recovered.TrySetResult(frame); + viewerCts.Cancel(); + }, viewerCts.Token); + + var runTask = hub.RunAsync(producerCts.Token); + await firstFrameStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + session.AllowDeltas.TrySetResult(); + await session.DeltasPublished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + releaseFirstFrame.TrySetResult(); + + var recoveryFrame = await recovered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(recoveryFrame.IsKeyFrame); + Assert.True(recoveryFrame.IsRecoveryKeyFrame); + Assert.Equal(ConsoleFrameUpdateKind.FullFrame, recoveryFrame.UpdateKind); + Assert.Equal(3, recoveryFrame.SequenceNumber); + Assert.Equal(new byte[] { 20, 30, 10 }, recoveryFrame.RawBytes); + + session.ReleaseProducer.TrySetResult(); + await viewerTask; + await runTask; + } + [Fact] public async Task AddViewerAfterNormalCompletionReturnsImmediately() { @@ -134,10 +181,66 @@ private sealed class ControlledSession : CompletingSession public override async Task StreamFramesAsync(ConsoleFrameStreamOptions options, Func onFrame, CancellationToken cancellationToken) { - await onFrame(new ConsoleFrame { SequenceNumber = 1, IsKeyFrame = true, UpdateKind = ConsoleFrameUpdateKind.FullFrame }, cancellationToken); - await onFrame(new ConsoleFrame { SequenceNumber = 2, IsKeyFrame = false, UpdateKind = ConsoleFrameUpdateKind.ChangedTiles }, cancellationToken); + await onFrame(FullFrame(1, 10, 10), cancellationToken); + await onFrame(TileFrame(2, 1, 20, 2, 10, 20), cancellationToken); InitialFramesPublished.TrySetResult(); await ReleaseProducer.Task.WaitAsync(cancellationToken); } } + + private sealed class SlowViewerSession : CompletingSession + { + public TaskCompletionSource AllowDeltas { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource DeltasPublished { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseProducer { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task StreamFramesAsync(ConsoleFrameStreamOptions options, Func onFrame, CancellationToken cancellationToken) + { + await onFrame(FullFrame(1, 10, 10, 10), cancellationToken); + await AllowDeltas.Task.WaitAsync(cancellationToken); + await onFrame(TileFrame(2, 0, 20, 3, 20, 10, 10), cancellationToken); + await onFrame(TileFrame(3, 1, 30, 3, 20, 30, 10), cancellationToken); + DeltasPublished.TrySetResult(); + await ReleaseProducer.Task.WaitAsync(cancellationToken); + } + } + + private static ConsoleFrame FullFrame(long sequenceNumber, params byte[] pixels) + { + return new ConsoleFrame + { + SequenceNumber = sequenceNumber, + Width = pixels.Length, + Height = 1, + PixelFormat = ConsoleFramePixelFormat.Rgb332, + UpdateKind = ConsoleFrameUpdateKind.FullFrame, + BytesPerPixelNumerator = 1, + BytesPerPixelDenominator = 1, + RawBytes = pixels, + Tiles = Array.Empty(), + IsKeyFrame = true, + PayloadBytes = pixels.Length + }; + } + + private static ConsoleFrame TileFrame(long sequenceNumber, int x, byte value, int frameWidth, params byte[] completeFrame) + { + return new ConsoleFrame + { + SequenceNumber = sequenceNumber, + Width = frameWidth, + Height = 1, + PixelFormat = ConsoleFramePixelFormat.Rgb332, + UpdateKind = ConsoleFrameUpdateKind.ChangedTiles, + BytesPerPixelNumerator = 1, + BytesPerPixelDenominator = 1, + CompleteFrameBytes = completeFrame, + Tiles = new[] + { + new ConsoleFrameTile { X = x, Y = 0, Width = 1, Height = 1, RawBytes = new[] { value } } + }, + IsKeyFrame = false, + PayloadBytes = 1 + }; + } } diff --git a/tests/HyperVConsoleKit.Tests/FramePacingTests.cs b/tests/HyperVConsoleKit.Tests/FramePacingTests.cs new file mode 100644 index 0000000..5c69d69 --- /dev/null +++ b/tests/HyperVConsoleKit.Tests/FramePacingTests.cs @@ -0,0 +1,44 @@ +using HyperVConsoleKit; + +namespace HyperVConsoleKit.Tests; + +public sealed class FramePacingTests +{ + [Fact] + public void InputActivityUsesActiveFrameRateEvenWithoutPixelChanges() + { + var options = new ConsoleFrameStreamOptions + { + ActiveFramesPerSecond = 8, + IdleFramesPerSecond = 1, + ActiveChangeThresholdBytes = 4096, + UseAdaptiveFrameRate = true + }; + + var target = HyperVConsoleSession.SelectTargetFramesPerSecond(options, 0, inputBoost: true); + + Assert.Equal(8, target); + } + + [Fact] + public async Task ActivitySignalInterruptsAnIdleDelay() + { + var signal = new ConsoleActivitySignal(); + var version = signal.Version; + var waiting = signal.WaitForPulseAsync(version, 10_000, CancellationToken.None); + + signal.Pulse(); + + Assert.True(await waiting.WaitAsync(TimeSpan.FromSeconds(1))); + } + + [Fact] + public async Task ActivitySignalObservesPulseThatPrecedesWait() + { + var signal = new ConsoleActivitySignal(); + var version = signal.Version; + signal.Pulse(); + + Assert.True(await signal.WaitForPulseAsync(version, 10_000, CancellationToken.None)); + } +} diff --git a/tests/HyperVConsoleKit.Tests/PixelCodecTests.cs b/tests/HyperVConsoleKit.Tests/PixelCodecTests.cs index 60c0a0a..db092f0 100644 --- a/tests/HyperVConsoleKit.Tests/PixelCodecTests.cs +++ b/tests/HyperVConsoleKit.Tests/PixelCodecTests.cs @@ -57,4 +57,30 @@ public void GetChangedTilesReturnsOnlyChangedTiles() Assert.Equal(2, tile.Height); Assert.Equal(new byte[] { 0, 0, 0, 0x7F }, tile.RawBytes); } + + [Theory] + [InlineData(ConsoleFramePixelFormat.Rgb565)] + [InlineData(ConsoleFramePixelFormat.Rgb332)] + [InlineData(ConsoleFramePixelFormat.Gray8)] + [InlineData(ConsoleFramePixelFormat.Gray4)] + [InlineData(ConsoleFramePixelFormat.Mono1)] + public void ApplyTilesReconstructsCurrentFrame(ConsoleFramePixelFormat format) + { + var previousRgb565 = new byte[10 * 4 * 2]; + var currentRgb565 = (byte[])previousRgb565.Clone(); + for (var pixel = 11; pixel < 29; pixel++) + { + currentRgb565[pixel * 2] = 0xff; + currentRgb565[pixel * 2 + 1] = 0xff; + } + + var previous = PixelCodec.ConvertRgb565(previousRgb565, format); + var current = PixelCodec.ConvertRgb565(currentRgb565, format); + var reconstructed = (byte[])previous.Clone(); + var tiles = PixelCodec.GetChangedTiles(previous, current, 10, 4, format, 3, 2); + + PixelCodec.ApplyTiles(reconstructed, 10, 4, format, tiles); + + Assert.Equal(current, reconstructed); + } } diff --git a/tests/HyperVConsoleKit.Tests/PolicyTests.cs b/tests/HyperVConsoleKit.Tests/PolicyTests.cs index e0e891a..1db7615 100644 --- a/tests/HyperVConsoleKit.Tests/PolicyTests.cs +++ b/tests/HyperVConsoleKit.Tests/PolicyTests.cs @@ -107,4 +107,17 @@ public void ApplyToStreamOptionsKeepsStricterExistingBandwidthLimit() Assert.Equal(100_000, options.MaxBytesPerSecond); } + + [Fact] + public void LatencyPresetUsesDocumentedPerformanceEnvelope() + { + var options = ConsoleFrameStreamOptions.CreatePreset(ConsoleStreamPreset.Latency); + + Assert.Equal(800, options.Width); + Assert.Equal(600, options.Height); + Assert.Equal(8, options.ActiveFramesPerSecond); + Assert.Equal(2, options.IdleFramesPerSecond); + Assert.Equal(900_000, options.MaxBytesPerSecond); + Assert.Equal(ConsoleFramePixelFormat.Rgb332, options.PixelFormat); + } } diff --git a/validation/compatibility-matrix.json b/validation/compatibility-matrix.json index 7db12a2..23b8c0c 100644 --- a/validation/compatibility-matrix.json +++ b/validation/compatibility-matrix.json @@ -1,5 +1,5 @@ { - "packageVersion": "0.6.0-preview.1", + "packageVersion": "0.6.0-preview.2", "required": [ { "operatingSystem": "Windows Server 2012 R2", @@ -24,13 +24,7 @@ { "operatingSystem": "Windows Server 2025", "architecture": "x64", - "status": "Pass", - "build": "26100", - "validatedUtc": "2026-07-27", - "identities": [ - "Elevated administrator", - "LocalSystem" - ] + "status": "Pending" }, { "operatingSystem": "Windows 10",