From b6df02882b5d629d94e9482787e68db6e6343603 Mon Sep 17 00:00:00 2001 From: DYH1319 <1245687900@qq.com> Date: Wed, 4 Nov 2026 14:56:40 +0800 Subject: [PATCH 1/2] fix(runtime): stop the socket bridge when it never connected When Electron exits before the socket bridge connects (for example because another instance holds the single-instance lock), RuntimeControllerDotNetFirst stops the bridge, but SocketBridgeService only reaches Stopped through BridgeDisconnected, which a never-connected socket does not raise. The controller then never reaches Stopped and the .NET host hangs forever. Co-Authored-By: Claude Opus 5.5 --- .../Services/SocketBridge/SocketBridgeService.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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(); From 1a0f287bf8793dfe6dd00485e9a4c437f170500e Mon Sep 17 00:00:00 2001 From: DYH1319 <1245687900@qq.com> Date: Wed, 4 Nov 2026 15:32:51 +0800 Subject: [PATCH 2/2] test(runtime): add lifecycle tests for the socket bridge and LifetimeServiceBase - SocketBridgeServiceLifecycleTests: a bridge that never connects (closed loopback port) reaches Stopped after Stop(), raises Stopping then Stopped exactly once, and is not moved out of Stopped by the pending connect attempt. These fail without the previous commit (the bridge stays in Stopping and Stopped is never raised). - LifetimeServiceBaseTests: Start/Stop transitions, Stopped before Ready completing every earlier wait task (what lets hosts awaiting WaitReadyTask observe a stopped runtime), same-state transitions being ignored, and backward transitions / Stop after Stopped throwing. No Electron runtime is required for either. Co-Authored-By: Claude Opus 5.5 --- .../Tests/LifetimeServiceBaseTests.cs | 111 ++++++++++++++++++ .../SocketBridgeServiceLifecycleTests.cs | 80 +++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 src/ElectronNET.IntegrationTests/Tests/LifetimeServiceBaseTests.cs create mode 100644 src/ElectronNET.IntegrationTests/Tests/SocketBridgeServiceLifecycleTests.cs 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; + } +}