From 171a0dbb86d969f9ff52e7f6ecf3fba31a59016a Mon Sep 17 00:00:00 2001 From: netscale1 Date: Wed, 19 Aug 2026 23:45:06 +0200 Subject: [PATCH 01/11] Wake reconnect on DNS-SD availability --- windows/Ech0Windows.Tests/ProtocolTests.cs | 64 +++++++++ windows/Ech0Windows/ConnectionWorker.cs | 160 +++++++++++++++++---- windows/Ech0Windows/DnsSdDiscovery.cs | 33 ++++- 3 files changed, 225 insertions(+), 32 deletions(-) diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index b91aba9..d08780e 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -432,6 +432,70 @@ public async Task ConnectionWorkerStartsWithProvidedPauseState() Assert.True(worker.IsPaused); } + [Fact] + public async Task ReconnectWaitWakesWhenDnsSdFindsTheService() + { + var fallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var reason = await ReconnectWait.WaitAsync( + fallback.Task, + Task.FromResult(true), + CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.Equal(ReconnectWakeReason.ServiceAvailable, reason); + } + + [Fact] + public async Task ReconnectWaitKeepsFallbackWhenDnsSdIsUnavailable() + { + var fallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var wait = ReconnectWait.WaitAsync( + fallback.Task, + Task.FromResult(false), + CancellationToken.None); + + Assert.False(wait.IsCompleted); + fallback.SetResult(); + + Assert.Equal(ReconnectWakeReason.FallbackDelay, await wait); + } + + [Fact] + public async Task ReconnectWaitKeepsFallbackWhenNoServiceAppears() + { + var serviceAvailable = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var reason = await ReconnectWait.WaitAsync( + Task.CompletedTask, + serviceAvailable.Task, + CancellationToken.None); + + Assert.Equal(ReconnectWakeReason.FallbackDelay, reason); + } + + [Fact] + public async Task ReconnectWaitObservesCancellation() + { + var fallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var serviceAvailable = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var wait = ReconnectWait.WaitAsync(fallback.Task, serviceAvailable.Task, cancellation.Token); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => wait); + } + + [Fact] + public async Task DnsSdBrowseCanBeCancelledCleanly() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + _ = await DnsSdDiscovery.WaitForServiceAsync(cancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(5)); + } + [Fact] public async Task ConnectionTaskGroupJoinsSiblingCleanupBeforePropagatingFailure() { diff --git a/windows/Ech0Windows/ConnectionWorker.cs b/windows/Ech0Windows/ConnectionWorker.cs index b0d1f7e..4d5d9e5 100644 --- a/windows/Ech0Windows/ConnectionWorker.cs +++ b/windows/Ech0Windows/ConnectionWorker.cs @@ -24,6 +24,36 @@ public static int Measure(ulong sentAtMs, ulong receivedAtMs) } } +internal enum ReconnectWakeReason +{ + FallbackDelay, + ServiceAvailable, +} + +internal static class ReconnectWait +{ + public static async Task WaitAsync( + Task fallbackDelay, + Task? serviceAvailable, + CancellationToken cancellationToken) + { + if (serviceAvailable is null) + { + await fallbackDelay.WaitAsync(cancellationToken); + return ReconnectWakeReason.FallbackDelay; + } + + var completed = await Task.WhenAny(fallbackDelay, serviceAvailable).WaitAsync(cancellationToken); + if (ReferenceEquals(completed, serviceAvailable) && await serviceAvailable) + { + return ReconnectWakeReason.ServiceAvailable; + } + + await fallbackDelay.WaitAsync(cancellationToken); + return ReconnectWakeReason.FallbackDelay; + } +} + internal sealed class ConnectionWorker : IAsyncDisposable { private readonly Ech0Settings settings; @@ -79,52 +109,121 @@ private async Task ApplyPauseChangeAsync() private async Task RunReconnectLoopAsync(CancellationToken cancellationToken) { var backoffSeconds = 1; - while (!cancellationToken.IsCancellationRequested) + var discoveryStarted = false; + CancellationTokenSource? discoveryStop = null; + Task? serviceAvailable = null; + + async Task StopDiscoveryAsync() { - StateChanged?.Invoke(this, AgentState.Connecting, null); - try + discoveryStop?.Cancel(); + if (serviceAvailable is not null) { - await RunConnectionAsync(cancellationToken); - backoffSeconds = 1; + try + { + await serviceAvailable; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + discoveryStop?.Dispose(); + discoveryStop = null; + serviceAvailable = null; + discoveryStarted = false; + } + + async Task WatchForServiceAsync(CancellationToken discoveryCancellationToken) + { + try { - break; + return await DnsSdDiscovery.WaitForServiceAsync(discoveryCancellationToken); } - catch (PairingRequiredException exception) + catch (OperationCanceledException) when (discoveryCancellationToken.IsCancellationRequested) { - Log.Write("pairing_required", exception.Message); - settings.MarkPairingRequired(); - SettingsStore.Save(settings); - StateChanged?.Invoke(this, AgentState.PairingRequired, null); - return; + return false; } catch (Exception exception) { - Log.Write("connection_failed", exception.GetType().Name); - StateChanged?.Invoke(this, AgentState.Disconnected, exception.GetType().Name); - } - finally - { - capture.Stop(); - stream?.Dispose(); - stream = null; - demandActive = false; + Log.Write("discovery_unavailable", exception.GetType().Name); + return false; } + } - try - { - await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken); - } - catch (OperationCanceledException) + async Task ConnectedAsync() + { + backoffSeconds = 1; + await StopDiscoveryAsync(); + } + + try + { + while (!cancellationToken.IsCancellationRequested) { - break; + StateChanged?.Invoke(this, AgentState.Connecting, null); + try + { + await RunConnectionAsync(cancellationToken, ConnectedAsync); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (PairingRequiredException exception) + { + Log.Write("pairing_required", exception.Message); + settings.MarkPairingRequired(); + SettingsStore.Save(settings); + StateChanged?.Invoke(this, AgentState.PairingRequired, null); + return; + } + catch (Exception exception) + { + Log.Write("connection_failed", exception.GetType().Name); + StateChanged?.Invoke(this, AgentState.Disconnected, exception.GetType().Name); + } + finally + { + capture.Stop(); + stream?.Dispose(); + stream = null; + demandActive = false; + } + + if (!discoveryStarted) + { + discoveryStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + serviceAvailable = WatchForServiceAsync(discoveryStop.Token); + discoveryStarted = true; + } + + try + { + var wakeReason = await ReconnectWait.WaitAsync( + Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken), + serviceAvailable, + cancellationToken); + if (wakeReason == ReconnectWakeReason.ServiceAvailable) + { + Log.Write("reconnect_discovery_wake"); + serviceAvailable = null; + } + } + catch (OperationCanceledException) + { + break; + } + backoffSeconds = Math.Min(backoffSeconds * 2, 8); } - backoffSeconds = Math.Min(backoffSeconds * 2, 8); + } + finally + { + await StopDiscoveryAsync(); } } - private async Task RunConnectionAsync(CancellationToken cancellationToken) + private async Task RunConnectionAsync( + CancellationToken cancellationToken, + Func connected) { demandGeneration = 0; demandActive = false; @@ -135,6 +234,7 @@ private async Task RunConnectionAsync(CancellationToken cancellationToken) settings, cancellationToken); stream = authenticated.Stream; + await connected(); lastPongTimestamp = Stopwatch.GetTimestamp(); Volatile.Write(ref lastRoundTripMs, -1); diff --git a/windows/Ech0Windows/DnsSdDiscovery.cs b/windows/Ech0Windows/DnsSdDiscovery.cs index 465636c..e338c17 100644 --- a/windows/Ech0Windows/DnsSdDiscovery.cs +++ b/windows/Ech0Windows/DnsSdDiscovery.cs @@ -7,6 +7,7 @@ internal sealed record DiscoveredService(string InstanceName, string HostName, i internal static class DnsSdDiscovery { + private const uint ErrorCancelled = 1223; private const uint DnsRequestPending = 9506; private const ushort DnsTypePtr = 12; @@ -23,13 +24,33 @@ internal static class DnsSdDiscovery return serviceName is null ? null : await ResolveAsync(serviceName, timeoutSource.Token); } + public static async Task WaitForServiceAsync(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10)) + { + return false; + } + + return await BrowseFirstNameAsync(cancellationToken) is not null; + } + private static async Task BrowseFirstNameAsync(CancellationToken cancellationToken) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - BrowseCallback callback = (_, _, records) => + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + BrowseCallback callback = (status, _, records) => { try { + if (status == ErrorCancelled) + { + return; + } + if (status != 0) + { + completion.TrySetResult(null); + return; + } for (var record = records; record != IntPtr.Zero; record = Marshal.ReadIntPtr(record, 0)) { if ((ushort)Marshal.ReadInt16(record, IntPtr.Size * 2) != DnsTypePtr) @@ -51,6 +72,10 @@ internal static class DnsSdDiscovery { DnsRecordListFree(records, 1); } + if (status == ErrorCancelled) + { + stopped.TrySetResult(); + } } }; var queryName = Marshal.StringToHGlobalUni("_ech0._tcp.local"); @@ -77,7 +102,11 @@ internal static class DnsSdDiscovery { if (cancel.Reserved != IntPtr.Zero) { - DnsServiceBrowseCancel(ref cancel); + var status = DnsServiceBrowseCancel(ref cancel); + if (status == 0) + { + await stopped.Task; + } } Marshal.FreeHGlobal(queryName); GC.KeepAlive(callback); From ceaa9e521cb013f83623be12fd6d2c00c08fda61 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Wed, 19 Aug 2026 23:47:09 +0200 Subject: [PATCH 02/11] Use test cancellation tokens --- windows/Ech0Windows.Tests/ProtocolTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index d08780e..58796be 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -440,7 +440,9 @@ public async Task ReconnectWaitWakesWhenDnsSdFindsTheService() var reason = await ReconnectWait.WaitAsync( fallback.Task, Task.FromResult(true), - CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)); + TestContext.Current.CancellationToken).WaitAsync( + TimeSpan.FromSeconds(1), + TestContext.Current.CancellationToken); Assert.Equal(ReconnectWakeReason.ServiceAvailable, reason); } @@ -452,7 +454,7 @@ public async Task ReconnectWaitKeepsFallbackWhenDnsSdIsUnavailable() var wait = ReconnectWait.WaitAsync( fallback.Task, Task.FromResult(false), - CancellationToken.None); + TestContext.Current.CancellationToken); Assert.False(wait.IsCompleted); fallback.SetResult(); @@ -468,7 +470,7 @@ public async Task ReconnectWaitKeepsFallbackWhenNoServiceAppears() var reason = await ReconnectWait.WaitAsync( Task.CompletedTask, serviceAvailable.Task, - CancellationToken.None); + TestContext.Current.CancellationToken); Assert.Equal(ReconnectWakeReason.FallbackDelay, reason); } @@ -493,7 +495,7 @@ public async Task DnsSdBrowseCanBeCancelledCleanly() cancellation.Cancel(); _ = await DnsSdDiscovery.WaitForServiceAsync(cancellation.Token) - .WaitAsync(TimeSpan.FromSeconds(5)); + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } [Fact] From a666bf91fcc74565421b86ae7ce820ec08e771a3 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Wed, 19 Aug 2026 23:49:17 +0200 Subject: [PATCH 03/11] Bound disconnected discovery window --- windows/Ech0Windows.Tests/ProtocolTests.cs | 4 +++- windows/Ech0Windows/ConnectionWorker.cs | 5 ++++- windows/Ech0Windows/DnsSdDiscovery.cs | 8 ++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index 58796be..7e693d1 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -494,7 +494,9 @@ public async Task DnsSdBrowseCanBeCancelledCleanly() using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - _ = await DnsSdDiscovery.WaitForServiceAsync(cancellation.Token) + _ = await DnsSdDiscovery.WaitForServiceAsync( + TimeSpan.FromSeconds(5), + cancellation.Token) .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } diff --git a/windows/Ech0Windows/ConnectionWorker.cs b/windows/Ech0Windows/ConnectionWorker.cs index 4d5d9e5..3c6e301 100644 --- a/windows/Ech0Windows/ConnectionWorker.cs +++ b/windows/Ech0Windows/ConnectionWorker.cs @@ -56,6 +56,7 @@ public static async Task WaitAsync( internal sealed class ConnectionWorker : IAsyncDisposable { + private static readonly TimeSpan DiscoveryWindow = TimeSpan.FromSeconds(30); private readonly Ech0Settings settings; private readonly AudioCaptureService capture = new(); private readonly SemaphoreSlim writeGate = new(1, 1); @@ -136,7 +137,9 @@ async Task WatchForServiceAsync(CancellationToken discoveryCancellationTok { try { - return await DnsSdDiscovery.WaitForServiceAsync(discoveryCancellationToken); + return await DnsSdDiscovery.WaitForServiceAsync( + DiscoveryWindow, + discoveryCancellationToken); } catch (OperationCanceledException) when (discoveryCancellationToken.IsCancellationRequested) { diff --git a/windows/Ech0Windows/DnsSdDiscovery.cs b/windows/Ech0Windows/DnsSdDiscovery.cs index e338c17..c7e7752 100644 --- a/windows/Ech0Windows/DnsSdDiscovery.cs +++ b/windows/Ech0Windows/DnsSdDiscovery.cs @@ -24,14 +24,18 @@ internal static class DnsSdDiscovery return serviceName is null ? null : await ResolveAsync(serviceName, timeoutSource.Token); } - public static async Task WaitForServiceAsync(CancellationToken cancellationToken) + public static async Task WaitForServiceAsync( + TimeSpan timeout, + CancellationToken cancellationToken) { if (!OperatingSystem.IsWindowsVersionAtLeast(10)) { return false; } - return await BrowseFirstNameAsync(cancellationToken) is not null; + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + return await BrowseFirstNameAsync(timeoutSource.Token) is not null; } private static async Task BrowseFirstNameAsync(CancellationToken cancellationToken) From 02359eaa787b221aa979f17b79eb6969269978b2 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Wed, 19 Aug 2026 23:53:58 +0200 Subject: [PATCH 04/11] Drop completed discovery waits --- windows/Ech0Windows/ConnectionWorker.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/windows/Ech0Windows/ConnectionWorker.cs b/windows/Ech0Windows/ConnectionWorker.cs index 3c6e301..7d2c906 100644 --- a/windows/Ech0Windows/ConnectionWorker.cs +++ b/windows/Ech0Windows/ConnectionWorker.cs @@ -210,6 +210,10 @@ async Task ConnectedAsync() Log.Write("reconnect_discovery_wake"); serviceAvailable = null; } + else if (serviceAvailable?.IsCompleted == true) + { + serviceAvailable = null; + } } catch (OperationCanceledException) { From ebee7afaf214f5a79f4e6963ef84d0ac215c5417 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 00:51:36 +0200 Subject: [PATCH 05/11] Keep reconnect discovery active --- windows/Ech0Windows.Tests/ProtocolTests.cs | 64 ++++++++++ windows/Ech0Windows/ConnectionWorker.cs | 40 ++++-- windows/Ech0Windows/DnsSdDiscovery.cs | 137 +++++++++++++++++++++ 3 files changed, 228 insertions(+), 13 deletions(-) diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index 7e693d1..eda3a2b 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -488,6 +488,57 @@ public async Task ReconnectWaitObservesCancellation() await Assert.ThrowsAnyAsync(() => wait); } + [Fact] + public async Task ServiceAvailabilityEventsPreserveARealWakeAfterAStaleWake() + { + var events = new ServiceAvailabilityEvents(); + var fallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + events.Notify(); + Assert.Equal( + ReconnectWakeReason.ServiceAvailable, + await ReconnectWait.WaitAsync( + fallback.Task, + events.WaitAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken)); + + var freshWake = ReconnectWait.WaitAsync( + fallback.Task, + events.WaitAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + Assert.False(freshWake.IsCompleted); + + events.Notify(); + Assert.Equal(ReconnectWakeReason.ServiceAvailable, await freshWake); + } + + [Fact] + public async Task ServiceAvailabilityEventsCoalesceCallbackBursts() + { + var events = new ServiceAvailabilityEvents(); + + events.Notify(); + events.Notify(); + Assert.True(await events.WaitAsync(TestContext.Current.CancellationToken)); + + var nextWake = events.WaitAsync(TestContext.Current.CancellationToken); + Assert.False(nextWake.IsCompleted); + + events.Notify(); + Assert.True(await nextWake); + } + + [Fact] + public async Task CompletingServiceAvailabilityEventsReleasesPendingWaiter() + { + var events = new ServiceAvailabilityEvents(); + var pending = events.WaitAsync(TestContext.Current.CancellationToken); + + events.Complete(); + + Assert.False(await pending); + } + [Fact] public async Task DnsSdBrowseCanBeCancelledCleanly() { @@ -500,6 +551,19 @@ public async Task DnsSdBrowseCanBeCancelledCleanly() .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } + [Fact] + public async Task DnsSdServiceWatcherCanBeDisposedCleanly() + { + var watcher = DnsSdDiscovery.StartServiceWatcher(); + if (watcher is null) + { + return; + } + + await watcher.DisposeAsync().AsTask() + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + [Fact] public async Task ConnectionTaskGroupJoinsSiblingCleanupBeforePropagatingFailure() { diff --git a/windows/Ech0Windows/ConnectionWorker.cs b/windows/Ech0Windows/ConnectionWorker.cs index 7d2c906..04b681d 100644 --- a/windows/Ech0Windows/ConnectionWorker.cs +++ b/windows/Ech0Windows/ConnectionWorker.cs @@ -56,7 +56,6 @@ public static async Task WaitAsync( internal sealed class ConnectionWorker : IAsyncDisposable { - private static readonly TimeSpan DiscoveryWindow = TimeSpan.FromSeconds(30); private readonly Ech0Settings settings; private readonly AudioCaptureService capture = new(); private readonly SemaphoreSlim writeGate = new(1, 1); @@ -111,37 +110,37 @@ private async Task RunReconnectLoopAsync(CancellationToken cancellationToken) { var backoffSeconds = 1; var discoveryStarted = false; - CancellationTokenSource? discoveryStop = null; + DnsSdDiscovery.ServiceWatcher? discovery = null; Task? serviceAvailable = null; async Task StopDiscoveryAsync() { - discoveryStop?.Cancel(); + if (discovery is not null) + { + await discovery.DisposeAsync(); + } if (serviceAvailable is not null) { try { await serviceAvailable; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) { } } - discoveryStop?.Dispose(); - discoveryStop = null; + discovery = null; serviceAvailable = null; discoveryStarted = false; } - async Task WatchForServiceAsync(CancellationToken discoveryCancellationToken) + async Task WatchForServiceAsync(DnsSdDiscovery.ServiceWatcher watcher) { try { - return await DnsSdDiscovery.WaitForServiceAsync( - DiscoveryWindow, - discoveryCancellationToken); + return await watcher.WaitAsync(cancellationToken); } - catch (OperationCanceledException) when (discoveryCancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return false; } @@ -194,10 +193,20 @@ async Task ConnectedAsync() if (!discoveryStarted) { - discoveryStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - serviceAvailable = WatchForServiceAsync(discoveryStop.Token); + try + { + discovery = DnsSdDiscovery.StartServiceWatcher(); + } + catch (Exception exception) + { + Log.Write("discovery_unavailable", exception.GetType().Name); + } discoveryStarted = true; } + if (discovery is not null && serviceAvailable is null) + { + serviceAvailable = WatchForServiceAsync(discovery); + } try { @@ -212,6 +221,11 @@ async Task ConnectedAsync() } else if (serviceAvailable?.IsCompleted == true) { + if (discovery is not null) + { + await discovery.DisposeAsync(); + discovery = null; + } serviceAvailable = null; } } diff --git a/windows/Ech0Windows/DnsSdDiscovery.cs b/windows/Ech0Windows/DnsSdDiscovery.cs index c7e7752..f0a60bb 100644 --- a/windows/Ech0Windows/DnsSdDiscovery.cs +++ b/windows/Ech0Windows/DnsSdDiscovery.cs @@ -1,10 +1,40 @@ using System.Net; using System.Runtime.InteropServices; +using System.Threading.Channels; namespace Ech0.Windows; internal sealed record DiscoveredService(string InstanceName, string HostName, int Port); +internal sealed class ServiceAvailabilityEvents +{ + private readonly Channel events = Channel.CreateBounded( + new BoundedChannelOptions(1) + { + AllowSynchronousContinuations = false, + FullMode = BoundedChannelFullMode.DropWrite, + SingleReader = true, + SingleWriter = false, + }); + + public void Notify() => events.Writer.TryWrite(0); + + public void Complete() => events.Writer.TryComplete(); + + public async Task WaitAsync(CancellationToken cancellationToken) + { + try + { + _ = await events.Reader.ReadAsync(cancellationToken); + return true; + } + catch (ChannelClosedException) + { + return false; + } + } +} + internal static class DnsSdDiscovery { private const uint ErrorCancelled = 1223; @@ -38,6 +68,113 @@ public static async Task WaitForServiceAsync( return await BrowseFirstNameAsync(timeoutSource.Token) is not null; } + public static ServiceWatcher? StartServiceWatcher() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10)) + { + return null; + } + + var watcher = new ServiceWatcher(); + return watcher.Start() ? watcher : null; + } + + internal sealed class ServiceWatcher : IAsyncDisposable + { + private readonly ServiceAvailabilityEvents events = new(); + private readonly TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly BrowseCallback callback; + private readonly IntPtr queryName; + private DnsServiceCancel cancel; + private int disposed; + + public ServiceWatcher() + { + callback = OnBrowse; + queryName = Marshal.StringToHGlobalUni("_ech0._tcp.local"); + } + + public Task WaitAsync(CancellationToken cancellationToken) => + events.WaitAsync(cancellationToken); + + public bool Start() + { + var request = new DnsServiceBrowseRequest + { + Version = 1, + InterfaceIndex = 0, + QueryName = queryName, + Callback = callback, + QueryContext = IntPtr.Zero, + }; + var status = DnsServiceBrowse(ref request, ref cancel); + if (status == DnsRequestPending) + { + return true; + } + + events.Complete(); + Marshal.FreeHGlobal(queryName); + disposed = 1; + return false; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + + events.Complete(); + if (cancel.Reserved != IntPtr.Zero) + { + var status = DnsServiceBrowseCancel(ref cancel); + if (status == 0) + { + await stopped.Task; + } + } + Marshal.FreeHGlobal(queryName); + GC.KeepAlive(callback); + } + + private void OnBrowse(uint status, IntPtr _, IntPtr records) + { + try + { + if (status == ErrorCancelled) + { + return; + } + if (status != 0) + { + events.Complete(); + return; + } + for (var record = records; record != IntPtr.Zero; record = Marshal.ReadIntPtr(record, 0)) + { + if ((ushort)Marshal.ReadInt16(record, IntPtr.Size * 2) == DnsTypePtr) + { + events.Notify(); + break; + } + } + } + finally + { + if (records != IntPtr.Zero) + { + DnsRecordListFree(records, 1); + } + if (status == ErrorCancelled) + { + stopped.TrySetResult(); + } + } + } + } + private static async Task BrowseFirstNameAsync(CancellationToken cancellationToken) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); From 16ff6f4b3ab1d59292ebce1b00d17dbcdba94102 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 01:25:55 +0200 Subject: [PATCH 06/11] Publish session-scoped receiver service --- macos/Sources/Ech0Mac/ReceiverServer.swift | 8 +++++++- .../ReceiverServiceInstanceNameTests.swift | 19 +++++++++++++++++++ windows/Ech0Windows/DnsSdDiscovery.cs | 5 ++--- windows/Ech0Windows/SettingsForm.cs | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 macos/Tests/Ech0MacTests/ReceiverServiceInstanceNameTests.swift diff --git a/macos/Sources/Ech0Mac/ReceiverServer.swift b/macos/Sources/Ech0Mac/ReceiverServer.swift index 4cd1924..e0d1ad5 100644 --- a/macos/Sources/Ech0Mac/ReceiverServer.swift +++ b/macos/Sources/Ech0Mac/ReceiverServer.swift @@ -109,6 +109,12 @@ struct ReceiverProtocolSupport { } } +enum ReceiverServiceInstanceName { + static func make(sessionID: UUID = UUID()) -> String { + "Ech0 \(sessionID.uuidString.lowercased())" + } +} + final class ReceiverServer: @unchecked Sendable { enum ConnectionState: Equatable { case idle @@ -227,7 +233,7 @@ final class ReceiverServer: @unchecked Sendable { } newListener.service = NWListener.Service( - name: Host.current().localizedName ?? "Ech0 Mac", + name: ReceiverServiceInstanceName.make(), type: "_ech0._tcp" ) diff --git a/macos/Tests/Ech0MacTests/ReceiverServiceInstanceNameTests.swift b/macos/Tests/Ech0MacTests/ReceiverServiceInstanceNameTests.swift new file mode 100644 index 0000000..0a4f4dc --- /dev/null +++ b/macos/Tests/Ech0MacTests/ReceiverServiceInstanceNameTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import Ech0Mac + +struct ReceiverServiceInstanceNameTests { + @Test + func sessionIdentityChangesTheDnsSdInstanceName() throws { + let firstSession = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let secondSession = try #require(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + + let firstName = ReceiverServiceInstanceName.make(sessionID: firstSession) + let secondName = ReceiverServiceInstanceName.make(sessionID: secondSession) + + #expect(firstName == "Ech0 11111111-1111-1111-1111-111111111111") + #expect(firstName != secondName) + #expect(firstName.utf8.count <= 63) + #expect(secondName.utf8.count <= 63) + } +} diff --git a/windows/Ech0Windows/DnsSdDiscovery.cs b/windows/Ech0Windows/DnsSdDiscovery.cs index f0a60bb..489302a 100644 --- a/windows/Ech0Windows/DnsSdDiscovery.cs +++ b/windows/Ech0Windows/DnsSdDiscovery.cs @@ -4,7 +4,7 @@ namespace Ech0.Windows; -internal sealed record DiscoveredService(string InstanceName, string HostName, int Port); +internal sealed record DiscoveredService(string HostName, int Port); internal sealed class ServiceAvailabilityEvents { @@ -267,12 +267,11 @@ private void OnBrowse(uint status, IntPtr _, IntPtr records) try { var instance = Marshal.PtrToStructure(instancePointer); - var resolvedName = Marshal.PtrToStringUni(instance.InstanceName) ?? instanceName; var hostName = Marshal.PtrToStringUni(instance.HostName)?.TrimEnd('.'); completion.TrySetResult( string.IsNullOrWhiteSpace(hostName) ? null - : new DiscoveredService(resolvedName, hostName, instance.Port)); + : new DiscoveredService(hostName, instance.Port)); } finally { diff --git a/windows/Ech0Windows/SettingsForm.cs b/windows/Ech0Windows/SettingsForm.cs index 9085f7c..1e713b2 100644 --- a/windows/Ech0Windows/SettingsForm.cs +++ b/windows/Ech0Windows/SettingsForm.cs @@ -283,7 +283,7 @@ private async Task DiscoverAsync() } host.Text = result.HostName; port.Value = result.Port; - discoveryStatus.Text = $"Found {result.InstanceName}"; + discoveryStatus.Text = $"Found {result.HostName}"; } catch (OperationCanceledException) when (formLifetime.IsCancellationRequested) { From c52e1360c58226c38738fedcbd6bce85bb4e01c8 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 01:55:15 +0200 Subject: [PATCH 07/11] Make Windows reconnect gate autonomous --- .github/workflows/ci.yml | 16 ++ scripts/windows-live-gate.ps1 | 257 ++++++++++++++++++ .../AutomationControlTests.cs | 97 +++++++ windows/Ech0Windows.Tests/ProtocolTests.cs | 17 ++ .../Ech0Windows/AgentApplicationContext.cs | 29 +- windows/Ech0Windows/AutomationControl.cs | 134 +++++++++ windows/Ech0Windows/DnsSdDiscovery.cs | 101 +++++-- windows/Ech0Windows/Program.cs | 13 +- 8 files changed, 633 insertions(+), 31 deletions(-) create mode 100644 scripts/windows-live-gate.ps1 create mode 100644 windows/Ech0Windows.Tests/AutomationControlTests.cs create mode 100644 windows/Ech0Windows/AutomationControl.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 661ba9b..4468eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,19 @@ jobs: - name: Test and build unsigned development artifact shell: pwsh run: ./scripts/release-windows.ps1 -AllowUnsignedDevelopment + - name: Smoke-test autonomous Windows gate + shell: pwsh + run: | + $candidate = Join-Path $PWD "dist\windows\publish\Ech0Windows.exe" + $state = Join-Path $env:RUNNER_TEMP "ech0-windows-live-gate.json" + $version = (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion + ./scripts/windows-live-gate.ps1 ` + -Action Start ` + -StatePath $state ` + -CandidateExecutable $candidate ` + -ExpectedProductVersion $version + try { + ./scripts/windows-live-gate.ps1 -Action Observe -StatePath $state + } finally { + ./scripts/windows-live-gate.ps1 -Action Stop -StatePath $state + } diff --git a/scripts/windows-live-gate.ps1 b/scripts/windows-live-gate.ps1 new file mode 100644 index 0000000..46c6a73 --- /dev/null +++ b/scripts/windows-live-gate.ps1 @@ -0,0 +1,257 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet("Start", "Observe", "Stop")] + [string]$Action, + + [Parameter(Mandatory)] + [string]$StatePath, + + [string]$CandidateExecutable, + [string]$ExpectedProductVersion, + [string]$StableExecutable, + [switch]$RestoreStable +) + +$ErrorActionPreference = "Stop" +$logPath = Join-Path $env:LOCALAPPDATA "Ech0\logs\ech0.log" +$settingsPath = Join-Path $env:LOCALAPPDATA "Ech0\settings.json" + +function Get-Ech0Processes { + @(Get-CimInstance Win32_Process | Where-Object Name -eq "Ech0Windows.exe") +} + +function Get-RegistryTreeHash { + param([string[]]$Paths) + + $items = [Collections.Generic.List[string]]::new() + foreach ($path in $Paths) { + if (-not (Test-Path -LiteralPath $path)) { continue } + $keys = @((Get-Item -LiteralPath $path)) + + @(Get-ChildItem -LiteralPath $path -Recurse -ErrorAction SilentlyContinue) + foreach ($key in $keys) { + $items.Add($key.Name) + $properties = Get-ItemProperty -LiteralPath $key.PSPath -ErrorAction SilentlyContinue + foreach ($property in @($properties.PSObject.Properties | Where-Object Name -NotMatch "^PS" | Sort-Object Name)) { + $value = if ($property.Value -is [byte[]]) { + [Convert]::ToBase64String($property.Value) + } else { + [string]$property.Value + } + $items.Add("$($property.Name)=$value") + } + } + } + $bytes = [Text.Encoding]::UTF8.GetBytes((@($items | Sort-Object) -join "`n")) + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) +} + +function Get-Invariants { + $audioPaths = @( + "HKCU:\Software\Microsoft\Multimedia\Sound Mapper", + "HKCU:\Software\Microsoft\Internet Explorer\LowRegistry\Audio\PolicyConfig" + ) + [ordered]@{ + SettingsSHA256 = if (Test-Path -LiteralPath $settingsPath) { + (Get-FileHash -LiteralPath $settingsPath -Algorithm SHA256).Hash + } else { $null } + RoutingSHA256 = Get-RegistryTreeHash -Paths $audioPaths + ParsecPids = @( + Get-Process -ErrorAction SilentlyContinue | + Where-Object ProcessName -Like "*parsec*" | + Sort-Object Id | + ForEach-Object { $_.Id } + ) + } +} + +function Send-ControlCommand { + param( + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][string]$Command, + [int]$TimeoutMilliseconds = 2_000 + ) + + $pipe = [IO.Pipes.NamedPipeClientStream]::new( + ".", + "Ech0WindowsAutomation-$Token", + [IO.Pipes.PipeDirection]::Out, + [IO.Pipes.PipeOptions]::Asynchronous) + try { + $pipe.Connect($TimeoutMilliseconds) + $writer = [IO.StreamWriter]::new($pipe, [Text.UTF8Encoding]::new($false), 128, $true) + try { + $writer.WriteLine($Command) + $writer.Flush() + } finally { + $writer.Dispose() + } + } finally { + $pipe.Dispose() + } +} + +function Read-State { + if (-not (Test-Path -LiteralPath $StatePath)) { + throw "Gate state does not exist: $StatePath" + } + Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json +} + +function Assert-CandidateIdentity { + param($State) + + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$($State.Pid)" + if ($null -eq $process -or $process.Name -ne "Ech0Windows.exe") { + throw "Candidate PID $($State.Pid) is not running." + } + if (-not $process.ExecutablePath.Equals($State.CandidatePath, [StringComparison]::OrdinalIgnoreCase)) { + throw "Candidate path changed; refusing control request." + } + $file = Get-Item -LiteralPath $State.CandidatePath + if ($file.VersionInfo.ProductVersion -ne $State.ProductVersion) { + throw "Candidate ProductVersion changed; refusing control request." + } + if ((Get-FileHash -LiteralPath $State.CandidatePath -Algorithm SHA256).Hash -ne $State.CandidateSHA256) { + throw "Candidate hash changed; refusing control request." + } + $process +} + +switch ($Action) { + "Start" { + if ([string]::IsNullOrWhiteSpace($CandidateExecutable) -or + [string]::IsNullOrWhiteSpace($ExpectedProductVersion)) { + throw "Start requires CandidateExecutable and ExpectedProductVersion." + } + if (Test-Path -LiteralPath $StatePath) { + throw "Gate state already exists; stop or inspect the previous candidate first." + } + if ((Get-Ech0Processes).Count -ne 0) { + throw "An Ech0Windows process is already running." + } + + $candidate = [IO.Path]::GetFullPath($CandidateExecutable) + $candidateFile = Get-Item -LiteralPath $candidate + if ($candidateFile.VersionInfo.ProductVersion -ne $ExpectedProductVersion) { + throw "Candidate ProductVersion does not match the expected value." + } + $stable = $null + if (-not [string]::IsNullOrWhiteSpace($StableExecutable)) { + $stablePath = [IO.Path]::GetFullPath($StableExecutable) + $stableFile = Get-Item -LiteralPath $stablePath + $stable = [ordered]@{ + Path = $stablePath + ProductVersion = $stableFile.VersionInfo.ProductVersion + SHA256 = (Get-FileHash -LiteralPath $stablePath -Algorithm SHA256).Hash + } + } + + $token = [Guid]::NewGuid().ToString("N") + $baseline = Get-Invariants + $logOffset = if (Test-Path -LiteralPath $logPath) { (Get-Item -LiteralPath $logPath).Length } else { 0 } + $started = Start-Process -FilePath $candidate -ArgumentList "--automation-control", $token -PassThru + $state = [ordered]@{ + SchemaVersion = 1 + Pid = $started.Id + Token = $token + CandidatePath = $candidate + ProductVersion = $candidateFile.VersionInfo.ProductVersion + CandidateSHA256 = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash + StartedAt = (Get-Date).ToString("o") + LogOffset = $logOffset + Baseline = $baseline + Stable = $stable + } + $parent = Split-Path -Parent ([IO.Path]::GetFullPath($StatePath)) + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $state | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + + $deadline = (Get-Date).AddSeconds(10) + do { + try { + Send-ControlCommand -Token $token -Command "probe" -TimeoutMilliseconds 250 + $controlReady = $true + } catch [TimeoutException] { + $controlReady = $false + } catch [IO.IOException] { + $controlReady = $false + } + if (-not $controlReady) { Start-Sleep -Milliseconds 100 } + } while (-not $controlReady -and -not $started.HasExited -and (Get-Date) -lt $deadline) + if (-not $controlReady -or $started.HasExited) { + throw "Candidate automation control did not become ready; gate state was preserved for recovery." + } + + Assert-CandidateIdentity -State ([pscustomobject]$state) | Out-Null + [pscustomobject]@{ Result = "started"; Pid = $started.Id; StatePath = $StatePath } + } + + "Observe" { + $state = Read-State + Assert-CandidateIdentity -State $state | Out-Null + $currentLength = if (Test-Path -LiteralPath $logPath) { (Get-Item -LiteralPath $logPath).Length } else { 0 } + $events = @() + if ($currentLength -gt $state.LogOffset) { + $stream = [IO.File]::Open($logPath, "Open", "Read", "ReadWrite") + try { + $stream.Seek($state.LogOffset, "Begin") | Out-Null + $buffer = [byte[]]::new($currentLength - $state.LogOffset) + $read = $stream.Read($buffer, 0, $buffer.Length) + } finally { + $stream.Dispose() + } + $events = @([Text.Encoding]::UTF8.GetString($buffer, 0, $read) -split "`r?`n" | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + $state.LogOffset = $currentLength + $state | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding utf8NoBOM + [pscustomobject]@{ Result = "observed"; Pid = $state.Pid; LogOffset = $currentLength; Events = $events } + } + + "Stop" { + $state = Read-State + $candidateFile = Get-Item -LiteralPath $state.CandidatePath + if ($candidateFile.VersionInfo.ProductVersion -ne $state.ProductVersion -or + (Get-FileHash -LiteralPath $state.CandidatePath -Algorithm SHA256).Hash -ne $state.CandidateSHA256) { + throw "Candidate artifact identity changed; refusing recovery." + } + if (Get-Process -Id $state.Pid -ErrorAction SilentlyContinue) { + Assert-CandidateIdentity -State $state | Out-Null + Send-ControlCommand -Token $state.Token -Command "shutdown" + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Process -Id $state.Pid -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Get-Process -Id $state.Pid -ErrorAction SilentlyContinue) { + throw "Candidate did not complete graceful shutdown." + } + } + + $final = Get-Invariants + if ($final.SettingsSHA256 -ne $state.Baseline.SettingsSHA256 -or + $final.RoutingSHA256 -ne $state.Baseline.RoutingSHA256 -or + (@($final.ParsecPids) -join ",") -ne (@($state.Baseline.ParsecPids) -join ",")) { + throw "Settings, routing, or Parsec changed during the gate." + } + + $stablePid = $null + if ($RestoreStable) { + if ($null -eq $state.Stable) { + throw "Stable restoration was not authorized when the gate started." + } + $stableFile = Get-Item -LiteralPath $state.Stable.Path + if ($stableFile.VersionInfo.ProductVersion -ne $state.Stable.ProductVersion -or + (Get-FileHash -LiteralPath $state.Stable.Path -Algorithm SHA256).Hash -ne $state.Stable.SHA256) { + throw "Stable executable identity changed; refusing restoration." + } + if ((Get-Ech0Processes).Count -ne 0) { + throw "An Ech0Windows process appeared before stable restoration." + } + $stablePid = (Start-Process -FilePath $state.Stable.Path -ArgumentList "--background" -PassThru).Id + } + + Remove-Item -LiteralPath $StatePath + [pscustomobject]@{ Result = "stopped"; CandidatePid = $state.Pid; StablePid = $stablePid } + } +} diff --git a/windows/Ech0Windows.Tests/AutomationControlTests.cs b/windows/Ech0Windows.Tests/AutomationControlTests.cs new file mode 100644 index 0000000..d7a3983 --- /dev/null +++ b/windows/Ech0Windows.Tests/AutomationControlTests.cs @@ -0,0 +1,97 @@ +using Xunit; + +namespace Ech0.Windows.Tests; + +public sealed class AutomationControlTests +{ + [Fact] + public void NormalStartupDoesNotEnableAutomationControl() + { + Assert.True(AutomationControlOptions.TryParse(["--background"], out var options)); + Assert.Null(options); + } + + [Fact] + public void ValidAutomationTokenIsNormalized() + { + Assert.True(AutomationControlOptions.TryParse( + ["--background", "--automation-control", "ABCDEF0123456789ABCDEF0123456789"], + out var options)); + + Assert.Equal("abcdef0123456789abcdef0123456789", options!.Token); + Assert.Equal( + "Ech0WindowsAutomation-abcdef0123456789abcdef0123456789", + options.PipeName); + } + + [Theory] + [InlineData("--automation-control")] + [InlineData("--automation-control", "not-a-guid")] + [InlineData( + "--automation-control", "abcdef0123456789abcdef0123456789", + "--automation-control", "0123456789abcdef0123456789abcdef")] + public void InvalidAutomationArgumentsFailClosed(params string[] args) + { + Assert.False(AutomationControlOptions.TryParse(args, out var options)); + Assert.Null(options); + } + + [Fact] + public async Task ShutdownCommandIsDeliveredExactlyOnce() + { + var options = new AutomationControlOptions(Guid.NewGuid().ToString("N")); + var shutdownCount = 0; + await using var listener = new AutomationShutdownListener( + options, + () => Interlocked.Increment(ref shutdownCount)); + await listener.Ready; + + Assert.True(await AutomationShutdownListener.RequestShutdownAsync( + options, + TimeSpan.FromSeconds(2), + TestContext.Current.CancellationToken)); + await WaitForAsync( + () => Volatile.Read(ref shutdownCount) == 1, + TestContext.Current.CancellationToken); + Assert.False(await AutomationShutdownListener.RequestShutdownAsync( + options, + TimeSpan.FromMilliseconds(100), + TestContext.Current.CancellationToken)); + Assert.Equal(1, Volatile.Read(ref shutdownCount)); + } + + [Fact] + public async Task DifferentTokenCannotStopListener() + { + var options = new AutomationControlOptions(Guid.NewGuid().ToString("N")); + var wrongOptions = new AutomationControlOptions(Guid.NewGuid().ToString("N")); + var shutdownCount = 0; + await using var listener = new AutomationShutdownListener( + options, + () => Interlocked.Increment(ref shutdownCount)); + await listener.Ready; + + Assert.False(await AutomationShutdownListener.RequestShutdownAsync( + wrongOptions, + TimeSpan.FromMilliseconds(100), + TestContext.Current.CancellationToken)); + Assert.Equal(0, Volatile.Read(ref shutdownCount)); + Assert.True(await AutomationShutdownListener.RequestShutdownAsync( + options, + TimeSpan.FromSeconds(2), + TestContext.Current.CancellationToken)); + await WaitForAsync( + () => Volatile.Read(ref shutdownCount) == 1, + TestContext.Current.CancellationToken); + } + + private static async Task WaitForAsync(Func condition, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + while (!condition()) + { + await Task.Delay(10, timeout.Token); + } + } +} diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index eda3a2b..557fd34 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Security.Cryptography; +using System.Runtime.InteropServices; using System.Text.Json; using Xunit; @@ -7,6 +8,22 @@ namespace Ech0.Windows.Tests; public sealed class ProtocolTests { + [Fact] + public void MdnsNativeLayoutsMatchTheWindowsX64Sdk() + { + Assert.Equal(64, Marshal.SizeOf()); + Assert.Equal(24, Marshal.OffsetOf( + nameof(DnsSdDiscovery.MdnsQueryRequest.QueryOptions)).ToInt32()); + Assert.Equal(40, Marshal.OffsetOf( + nameof(DnsSdDiscovery.MdnsQueryRequest.Callback)).ToInt32()); + Assert.Equal(544, Marshal.SizeOf()); + Assert.Equal(520, Marshal.OffsetOf( + nameof(DnsSdDiscovery.MdnsQueryHandle.Subscription)).ToInt32()); + Assert.Equal(32, Marshal.SizeOf()); + Assert.Equal(16, Marshal.OffsetOf( + nameof(DnsSdDiscovery.DnsQueryResult.QueryRecords)).ToInt32()); + } + [Fact] public void LoggingIoFailureDoesNotEscapeIntoRuntime() { diff --git a/windows/Ech0Windows/AgentApplicationContext.cs b/windows/Ech0Windows/AgentApplicationContext.cs index bb915f7..f2fed41 100644 --- a/windows/Ech0Windows/AgentApplicationContext.cs +++ b/windows/Ech0Windows/AgentApplicationContext.cs @@ -14,13 +14,14 @@ internal sealed class AgentApplicationContext : ApplicationContext private readonly Icon unavailableIcon = TrayIcons.Load("Ech0Unavailable.ico"); private readonly Icon capturingIcon = TrayIcons.Load("Ech0Capturing.ico"); private readonly SemaphoreSlim workerLifecycleGate = new(1, 1); + private AutomationShutdownListener? automationControl; private Ech0Settings settings; private ConnectionWorker? worker; private bool paused; private bool isExiting; private AgentState currentState = AgentState.Disconnected; - public AgentApplicationContext() + public AgentApplicationContext(AutomationControlOptions? automationControlOptions = null) { settings = SettingsStore.Load(); dispatcher.CreateControl(); @@ -41,6 +42,13 @@ public AgentApplicationContext() tray.Visible = true; tray.DoubleClick += (_, _) => ShowSettings(); + if (automationControlOptions is not null) + { + automationControl = new AutomationShutdownListener( + automationControlOptions, + RequestAutomationExit); + } + if (!settings.IsConfigured) { ShowSettings(); @@ -206,17 +214,36 @@ private static void OpenLogs() private async Task ExitAsync() { + if (isExiting) + { + return; + } isExiting = true; tray.Visible = false; await StopWorkerAsync(); + if (automationControl is not null) + { + await automationControl.DisposeAsync(); + automationControl = null; + } tray.Dispose(); ExitThread(); } + private void RequestAutomationExit() + { + if (!dispatcher.IsDisposed) + { + dispatcher.BeginInvoke(new Action(() => _ = ExitAsync())); + } + } + protected override void Dispose(bool disposing) { if (disposing) { + automationControl?.Dispose(); + automationControl = null; tray.Dispose(); dispatcher.Dispose(); disconnectedIcon.Dispose(); diff --git a/windows/Ech0Windows/AutomationControl.cs b/windows/Ech0Windows/AutomationControl.cs new file mode 100644 index 0000000..65cb141 --- /dev/null +++ b/windows/Ech0Windows/AutomationControl.cs @@ -0,0 +1,134 @@ +using System.IO.Pipes; +using System.Text; + +namespace Ech0.Windows; + +internal sealed record AutomationControlOptions(string Token) +{ + private const string Flag = "--automation-control"; + + public string PipeName => $"Ech0WindowsAutomation-{Token}"; + + public static bool TryParse(string[] args, out AutomationControlOptions? options) + { + options = null; + var flagIndex = Array.IndexOf(args, Flag); + if (flagIndex < 0) + { + return true; + } + if (flagIndex + 1 >= args.Length || Array.LastIndexOf(args, Flag) != flagIndex) + { + return false; + } + if (!Guid.TryParseExact(args[flagIndex + 1], "N", out var token)) + { + return false; + } + + options = new AutomationControlOptions(token.ToString("N")); + return true; + } +} + +internal sealed class AutomationShutdownListener : IAsyncDisposable, IDisposable +{ + private const string ShutdownCommand = "shutdown"; + private readonly CancellationTokenSource stop = new(); + private readonly Action shutdownRequested; + private readonly Task runTask; + private int disposed; + + public Task Ready { get; } + + public AutomationShutdownListener(AutomationControlOptions options, Action shutdownRequested) + { + this.shutdownRequested = shutdownRequested; + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Ready = ready.Task; + runTask = RunAsync(options.PipeName, ready, stop.Token); + } + + public static async Task RequestShutdownAsync( + AutomationControlOptions options, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + try + { + await using var pipe = new NamedPipeClientStream( + ".", + options.PipeName, + PipeDirection.Out, + PipeOptions.Asynchronous); + await pipe.ConnectAsync(timeoutSource.Token); + await using var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) + { + AutoFlush = true, + }; + await writer.WriteLineAsync(ShutdownCommand.AsMemory(), timeoutSource.Token); + return true; + } + catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) + { + return false; + } + catch (IOException) + { + return false; + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + await stop.CancelAsync(); + await runTask; + stop.Dispose(); + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + private async Task RunAsync( + string pipeName, + TaskCompletionSource ready, + CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await using var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.In, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + ready.TrySetResult(); + await pipe.WaitForConnectionAsync(cancellationToken); + using var reader = new StreamReader(pipe, Encoding.UTF8, leaveOpen: true); + var command = await reader.ReadLineAsync(cancellationToken); + if (command == ShutdownCommand) + { + shutdownRequested(); + return; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + ready.TrySetResult(); + } + } +} diff --git a/windows/Ech0Windows/DnsSdDiscovery.cs b/windows/Ech0Windows/DnsSdDiscovery.cs index 489302a..9b4a300 100644 --- a/windows/Ech0Windows/DnsSdDiscovery.cs +++ b/windows/Ech0Windows/DnsSdDiscovery.cs @@ -82,15 +82,14 @@ public static async Task WaitForServiceAsync( internal sealed class ServiceWatcher : IAsyncDisposable { private readonly ServiceAvailabilityEvents events = new(); - private readonly TaskCompletionSource stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly BrowseCallback callback; + private readonly MdnsQueryCallback callback; private readonly IntPtr queryName; - private DnsServiceCancel cancel; + private MdnsQueryHandle handle; private int disposed; public ServiceWatcher() { - callback = OnBrowse; + callback = OnResult; queryName = Marshal.StringToHGlobalUni("_ech0._tcp.local"); } @@ -99,16 +98,21 @@ public Task WaitAsync(CancellationToken cancellationToken) => public bool Start() { - var request = new DnsServiceBrowseRequest + var request = new MdnsQueryRequest { Version = 1, + ReferenceCount = 0, + Query = queryName, + QueryType = DnsTypePtr, + QueryOptions = 0, InterfaceIndex = 0, - QueryName = queryName, Callback = callback, QueryContext = IntPtr.Zero, + AnswerReceived = 0, + ResendCount = 0, }; - var status = DnsServiceBrowse(ref request, ref cancel); - if (status == DnsRequestPending) + var status = DnsStartMulticastQuery(ref request, ref handle); + if (status == 0) { return true; } @@ -127,34 +131,32 @@ public async ValueTask DisposeAsync() } events.Complete(); - if (cancel.Reserved != IntPtr.Zero) - { - var status = DnsServiceBrowseCancel(ref cancel); - if (status == 0) - { - await stopped.Task; - } - } + _ = DnsStopMulticastQuery(ref handle); Marshal.FreeHGlobal(queryName); GC.KeepAlive(callback); + await ValueTask.CompletedTask; } - private void OnBrowse(uint status, IntPtr _, IntPtr records) + private void OnResult(IntPtr _, IntPtr __, IntPtr resultPointer) { + if (resultPointer == IntPtr.Zero) + { + return; + } + var result = Marshal.PtrToStructure(resultPointer); + var records = result.QueryRecords; try { - if (status == ErrorCancelled) - { - return; - } - if (status != 0) + if (result.QueryStatus != 0) { events.Complete(); return; } for (var record = records; record != IntPtr.Zero; record = Marshal.ReadIntPtr(record, 0)) { - if ((ushort)Marshal.ReadInt16(record, IntPtr.Size * 2) == DnsTypePtr) + var type = (ushort)Marshal.ReadInt16(record, IntPtr.Size * 2); + var timeToLive = (uint)Marshal.ReadInt32(record, 24); + if (type == DnsTypePtr && timeToLive > 0) { events.Notify(); break; @@ -167,10 +169,6 @@ private void OnBrowse(uint status, IntPtr _, IntPtr records) { DnsRecordListFree(records, 1); } - if (status == ErrorCancelled) - { - stopped.TrySetResult(); - } } } } @@ -315,6 +313,47 @@ private void OnBrowse(uint status, IntPtr _, IntPtr records) [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate void ResolveCallback(uint status, IntPtr queryContext, IntPtr instance); + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + internal delegate void MdnsQueryCallback( + IntPtr queryContext, + IntPtr queryHandle, + IntPtr queryResults); + + [StructLayout(LayoutKind.Sequential)] + internal struct MdnsQueryRequest + { + public uint Version; + public uint ReferenceCount; + public IntPtr Query; + public ushort QueryType; + public ulong QueryOptions; + public uint InterfaceIndex; + public MdnsQueryCallback Callback; + public IntPtr QueryContext; + public int AnswerReceived; + public uint ResendCount; + } + + [StructLayout(LayoutKind.Explicit, Size = 544)] + internal struct MdnsQueryHandle + { + [FieldOffset(512)] public ushort Type; + [FieldOffset(520)] public IntPtr Subscription; + [FieldOffset(528)] public IntPtr CallbackParameters; + [FieldOffset(536)] public uint StateNameData0; + [FieldOffset(540)] public uint StateNameData1; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DnsQueryResult + { + public uint Version; + public uint QueryStatus; + public ulong QueryOptions; + public IntPtr QueryRecords; + public IntPtr Reserved; + } + [StructLayout(LayoutKind.Sequential)] private struct DnsServiceBrowseRequest { @@ -369,6 +408,14 @@ private struct DnsServiceInstance [DllImport("dnsapi.dll")] private static extern uint DnsServiceResolveCancel(ref DnsServiceCancel cancel); + [DllImport("dnsapi.dll", CharSet = CharSet.Unicode)] + private static extern uint DnsStartMulticastQuery( + ref MdnsQueryRequest queryRequest, + ref MdnsQueryHandle handle); + + [DllImport("dnsapi.dll")] + private static extern uint DnsStopMulticastQuery(ref MdnsQueryHandle handle); + [DllImport("dnsapi.dll")] private static extern void DnsServiceFreeInstance(IntPtr instance); diff --git a/windows/Ech0Windows/Program.cs b/windows/Ech0Windows/Program.cs index 679fc47..b87907b 100644 --- a/windows/Ech0Windows/Program.cs +++ b/windows/Ech0Windows/Program.cs @@ -3,14 +3,21 @@ namespace Ech0.Windows; internal static class Program { [STAThread] - private static void Main() + private static int Main(string[] args) { + if (!AutomationControlOptions.TryParse(args, out var automationControl)) + { + return 2; + } + ApplicationConfiguration.Initialize(); using var mutex = new Mutex(true, "Local\\Ech0WindowsAgent", out var ownsMutex); if (!ownsMutex) { - return; + return 0; } - Application.Run(new AgentApplicationContext()); + using var context = new AgentApplicationContext(automationControl); + Application.Run(context); + return 0; } } From 1c9c541511ad8b96267829d5cbaf2536518d3f7c Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 01:56:56 +0200 Subject: [PATCH 08/11] Fix Windows live gate syntax --- scripts/windows-live-gate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/windows-live-gate.ps1 b/scripts/windows-live-gate.ps1 index 46c6a73..56aa108 100644 --- a/scripts/windows-live-gate.ps1 +++ b/scripts/windows-live-gate.ps1 @@ -69,7 +69,7 @@ function Send-ControlCommand { param( [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$Command, - [int]$TimeoutMilliseconds = 2_000 + [int]$TimeoutMilliseconds = 2000 ) $pipe = [IO.Pipes.NamedPipeClientStream]::new( From b4124b0a94476dc7806e58bfdb75d5ff663495c0 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 01:57:17 +0200 Subject: [PATCH 09/11] Exercise native mDNS watcher on Windows --- windows/Ech0Windows.Tests/ProtocolTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/windows/Ech0Windows.Tests/ProtocolTests.cs b/windows/Ech0Windows.Tests/ProtocolTests.cs index 557fd34..dd5d743 100644 --- a/windows/Ech0Windows.Tests/ProtocolTests.cs +++ b/windows/Ech0Windows.Tests/ProtocolTests.cs @@ -24,6 +24,14 @@ public void MdnsNativeLayoutsMatchTheWindowsX64Sdk() nameof(DnsSdDiscovery.DnsQueryResult.QueryRecords)).ToInt32()); } + [Fact] + public async Task MdnsWatcherStartsAndStopsWithTheWindowsApi() + { + await using var watcher = new DnsSdDiscovery.ServiceWatcher(); + + Assert.True(watcher.Start()); + } + [Fact] public void LoggingIoFailureDoesNotEscapeIntoRuntime() { From 878e49bf83d94dd22aee60ce5e79dec3c47db542 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 01:59:34 +0200 Subject: [PATCH 10/11] Keep automation startup non-interactive --- windows/Ech0Windows/AgentApplicationContext.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/Ech0Windows/AgentApplicationContext.cs b/windows/Ech0Windows/AgentApplicationContext.cs index f2fed41..532b946 100644 --- a/windows/Ech0Windows/AgentApplicationContext.cs +++ b/windows/Ech0Windows/AgentApplicationContext.cs @@ -51,7 +51,14 @@ public AgentApplicationContext(AutomationControlOptions? automationControlOption if (!settings.IsConfigured) { - ShowSettings(); + if (automationControlOptions is null) + { + ShowSettings(); + } + else + { + SetState(AgentState.PairingRequired, null); + } } else { From 747403faee6135c5855ad76e8f355567005ae347 Mon Sep 17 00:00:00 2001 From: netscale1 Date: Thu, 20 Aug 2026 07:51:49 +0200 Subject: [PATCH 11/11] Avoid WMI in Windows live gate --- scripts/windows-live-gate.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/windows-live-gate.ps1 b/scripts/windows-live-gate.ps1 index 56aa108..43dd95a 100644 --- a/scripts/windows-live-gate.ps1 +++ b/scripts/windows-live-gate.ps1 @@ -18,7 +18,7 @@ $logPath = Join-Path $env:LOCALAPPDATA "Ech0\logs\ech0.log" $settingsPath = Join-Path $env:LOCALAPPDATA "Ech0\settings.json" function Get-Ech0Processes { - @(Get-CimInstance Win32_Process | Where-Object Name -eq "Ech0Windows.exe") + @(Get-Process -Name "Ech0Windows" -ErrorAction SilentlyContinue) } function Get-RegistryTreeHash { @@ -101,11 +101,12 @@ function Read-State { function Assert-CandidateIdentity { param($State) - $process = Get-CimInstance Win32_Process -Filter "ProcessId=$($State.Pid)" - if ($null -eq $process -or $process.Name -ne "Ech0Windows.exe") { + $process = Get-Process -Id $State.Pid -ErrorAction SilentlyContinue + if ($null -eq $process -or $process.ProcessName -ne "Ech0Windows") { throw "Candidate PID $($State.Pid) is not running." } - if (-not $process.ExecutablePath.Equals($State.CandidatePath, [StringComparison]::OrdinalIgnoreCase)) { + if ([string]::IsNullOrWhiteSpace($process.Path) -or + -not $process.Path.Equals($State.CandidatePath, [StringComparison]::OrdinalIgnoreCase)) { throw "Candidate path changed; refusing control request." } $file = Get-Item -LiteralPath $State.CandidatePath