Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/COMPATIBILITY_VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/PACKAGE_DEPENDENCIES.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down
119 changes: 105 additions & 14 deletions samples/AspNetCoreWebConsole/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HyperVConsoleAuditEvent>();
var inputCompletions = new ConcurrentDictionary<string, InputCompletion>();
client.Activity += (_, e) =>
{
auditLog.Enqueue(e);
Expand Down Expand Up @@ -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();
});
});
Expand Down Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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;
}

Expand All @@ -297,6 +307,7 @@ await sessionManager.AddViewerAsync(id, options, async (frame, cancellationToken
finally
{
await inputLeases.ReleaseAsync(lease, id);
inputCompletions.TryRemove(GetInputCompletionKey(id, lease), out _);
}
});

Expand Down Expand Up @@ -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<string, InputCompletion> 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<byte>()
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
Expand All @@ -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`;
Expand All @@ -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}`;
};
}

Expand All @@ -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 => {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 = {}) {
Expand All @@ -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');
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions samples/AspNetCoreWebConsole/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"InputLeaseMinutes": 5,
"MaxWidth": 1024,
"MaxHeight": 768,
"MaxFramesPerSecond": 5,
"MaxBytesPerSecond": 500000,
"MaxFramesPerSecond": 8,
"MaxBytesPerSecond": 900000,
"MaxColorDepth": "Rgb332",
"MaxConcurrentViewers": 3,
"AllowKeyboardInput": true,
Expand Down
Loading