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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
111 changes: 111 additions & 0 deletions src/ElectronNET.IntegrationTests/Tests/LifetimeServiceBaseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
namespace ElectronNET.IntegrationTests.Tests;

using ElectronNET.Runtime.Data;
using ElectronNET.Runtime.Services;

/// <summary>
/// State machine checks for <see cref="LifetimeServiceBase"/>, which the runtime controller and its
/// services (Electron process, socket bridge) build on.
/// No Electron runtime is required for these tests.
/// </summary>
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<string>();
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<Exception>().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<Exception>().WithMessage("Invalid state*");
}

private sealed class TestLifetimeService : LifetimeServiceBase
{
public void Transition(LifetimeState state) => this.TransitionState(state);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Lifecycle checks for <see cref="SocketBridgeService"/> 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.
/// </summary>
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<string>();
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;
}
}
Loading