diff --git a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs index dd40033c..1a80adfc 100644 --- a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs +++ b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs @@ -51,6 +51,19 @@ protected override Task StopCore() return Task.CompletedTask; } + public override async Task Stop() + { + await base.Stop().ConfigureAwait(false); + + // A socket that never connected never raises BridgeDisconnected, so without this the bridge stays in + // Stopping forever. That happens when Electron exits before the bridge connects (e.g. it lost the + // single-instance lock), and the runtime controller then never reaches Stopped, hanging the .NET host. + if (this.State < LifetimeState.Stopped) + { + this.TransitionState(LifetimeState.Stopped); + } + } + private void Connect() { this.socket.Connect(); diff --git a/src/ElectronNET.IntegrationTests/Tests/LifetimeServiceBaseTests.cs b/src/ElectronNET.IntegrationTests/Tests/LifetimeServiceBaseTests.cs new file mode 100644 index 00000000..e13079eb --- /dev/null +++ b/src/ElectronNET.IntegrationTests/Tests/LifetimeServiceBaseTests.cs @@ -0,0 +1,111 @@ +namespace ElectronNET.IntegrationTests.Tests; + +using ElectronNET.Runtime.Data; +using ElectronNET.Runtime.Services; + +/// +/// State machine checks for , which the runtime controller and its +/// services (Electron process, socket bridge) build on. +/// No Electron runtime is required for these tests. +/// +public class LifetimeServiceBaseTests +{ + [Fact] + public async Task Start_TransitionsToStarting() + { + var service = new TestLifetimeService(); + + await service.Start(); + + service.State.Should().Be(LifetimeState.Starting); + service.WaitStartedTask.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task Stop_TransitionsToStoppingWithoutCompletingStopped() + { + var service = new TestLifetimeService(); + await service.Start(); + + await service.Stop(); + + service.State.Should().Be(LifetimeState.Stopping); + service.WaitStoppingTask.IsCompleted.Should().BeTrue(); + service.WaitStoppedTask.IsCompleted.Should().BeFalse( + "reaching Stopped is up to the concrete service once it has actually shut down"); + } + + [Fact] + public async Task Stopped_BeforeReady_CompletesEveryEarlierWaitTask() + { + var service = new TestLifetimeService(); + await service.Start(); + + service.Transition(LifetimeState.Stopped); + + service.WaitStartedTask.IsCompleted.Should().BeTrue(); + service.WaitReadyTask.IsCompleted.Should().BeTrue( + "callers awaiting readiness must not hang when the service stops before it ever became ready"); + service.WaitStoppingTask.IsCompleted.Should().BeTrue(); + service.WaitStoppedTask.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Stopped_RaisesOnlyTheStoppedEvent() + { + var service = new TestLifetimeService(); + var events = new List(); + service.Started += (_, _) => events.Add(nameof(service.Started)); + service.Ready += (_, _) => events.Add(nameof(service.Ready)); + service.Stopping += (_, _) => events.Add(nameof(service.Stopping)); + service.Stopped += (_, _) => events.Add(nameof(service.Stopped)); + await service.Start(); + + service.Transition(LifetimeState.Stopped); + + events.Should().Equal(nameof(service.Stopped)); + } + + [Fact] + public async Task Transition_ToTheCurrentState_IsIgnored() + { + var service = new TestLifetimeService(); + var stoppedCount = 0; + service.Stopped += (_, _) => stoppedCount++; + await service.Start(); + + service.Transition(LifetimeState.Stopped); + service.Transition(LifetimeState.Stopped); + + stoppedCount.Should().Be(1); + } + + [Fact] + public async Task Transition_Backwards_Throws() + { + var service = new TestLifetimeService(); + await service.Start(); + service.Transition(LifetimeState.Stopped); + + var transition = () => service.Transition(LifetimeState.Stopping); + + transition.Should().Throw().WithMessage("Invalid state transition*"); + } + + [Fact] + public async Task Stop_AfterStopped_Throws() + { + var service = new TestLifetimeService(); + await service.Start(); + service.Transition(LifetimeState.Stopped); + + var stop = () => service.Stop(); + + await stop.Should().ThrowAsync().WithMessage("Invalid state*"); + } + + private sealed class TestLifetimeService : LifetimeServiceBase + { + public void Transition(LifetimeState state) => this.TransitionState(state); + } +} diff --git a/src/ElectronNET.IntegrationTests/Tests/SocketBridgeServiceLifecycleTests.cs b/src/ElectronNET.IntegrationTests/Tests/SocketBridgeServiceLifecycleTests.cs new file mode 100644 index 00000000..53be3713 --- /dev/null +++ b/src/ElectronNET.IntegrationTests/Tests/SocketBridgeServiceLifecycleTests.cs @@ -0,0 +1,80 @@ +namespace ElectronNET.IntegrationTests.Tests; + +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using ElectronNET.Runtime.Data; +using ElectronNET.Runtime.Services.SocketBridge; + +/// +/// Lifecycle checks for when the bridge never connects. +/// This happens when Electron exits before the bridge connects, e.g. a second instance that loses the +/// single-instance lock: the runtime controller stops the bridge and waits for it to reach Stopped. +/// No Electron runtime is required for these tests. +/// +public class SocketBridgeServiceLifecycleTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + public SocketBridgeServiceLifecycleTests() + { + // SocketBridgeService reads ElectronNetRuntime, whose static initializer gathers the build info from the + // test assembly under testhost (same as ElectronFixture); it has to be registered before first access. + AppDomain.CurrentDomain.SetData("ElectronTestAssembly", Assembly.GetExecutingAssembly()); + } + + [Fact] + public async Task Stop_WhenNeverConnected_TransitionsToStopped() + { + var bridge = new SocketBridgeService(GetClosedLoopbackPort(), "token"); + + await bridge.Start(); + await bridge.Stop().WaitAsync(Timeout); + + bridge.State.Should().Be(LifetimeState.Stopped); + bridge.WaitStoppingTask.IsCompleted.Should().BeTrue(); + bridge.WaitStoppedTask.IsCompleted.Should().BeTrue( + "a socket that never connected never raises BridgeDisconnected, so Stop() itself must complete the lifecycle"); + } + + [Fact] + public async Task Stop_WhenNeverConnected_RaisesStoppingThenStoppedOnce() + { + var bridge = new SocketBridgeService(GetClosedLoopbackPort(), "token"); + var events = new List(); + bridge.Stopping += (_, _) => { lock (events) events.Add(nameof(bridge.Stopping)); }; + bridge.Stopped += (_, _) => { lock (events) events.Add(nameof(bridge.Stopped)); }; + bridge.Ready += (_, _) => { lock (events) events.Add(nameof(bridge.Ready)); }; + + await bridge.Start(); + await bridge.Stop().WaitAsync(Timeout); + + lock (events) + { + events.Should().Equal(nameof(bridge.Stopping), nameof(bridge.Stopped)); + } + } + + [Fact] + public async Task Stop_WhenNeverConnected_IsNotRevivedByThePendingConnectAttempt() + { + var bridge = new SocketBridgeService(GetClosedLoopbackPort(), "token"); + + await bridge.Start(); + await bridge.Stop().WaitAsync(Timeout); + + // The connect attempt started by Start() runs in the background; once it gives up it must not move the + // service out of Stopped (backward transitions would throw on the thread pool). + await Task.Delay(TimeSpan.FromSeconds(1)); + bridge.State.Should().Be(LifetimeState.Stopped); + } + + private static int GetClosedLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +}