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;
+ }
+}