Skip to content

Commit

Permalink
Merge pull request #6442 from peppy/named-pipes-for-ipc
Browse files Browse the repository at this point in the history
Switch to using named pipes for IPC
  • Loading branch information
smoogipoo authored Dec 6, 2024
2 parents 13e82bd + bbec913 commit 2e19f17
Show file tree
Hide file tree
Showing 9 changed files with 461 additions and 63 deletions.
42 changes: 0 additions & 42 deletions osu.Framework.Tests/Platform/HeadlessGameHostTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@

using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Tests.IO;

namespace osu.Framework.Tests.Platform
{
Expand Down Expand Up @@ -86,46 +84,6 @@ public void TestThreadSafetyResetOnEnteringThread()
}
}

[Test]
public void TestIpc()
{
using (var server = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPort = 45356 }))
using (var client = new HeadlessGameHost(@"client", new HostOptions { IPCPort = 45356 }))
{
Assert.IsTrue(server.IsPrimaryInstance, @"Server wasn't able to bind");
Assert.IsFalse(client.IsPrimaryInstance, @"Client was able to bind when it shouldn't have been able to");

var serverChannel = new IpcChannel<Foobar>(server);
var clientChannel = new IpcChannel<Foobar>(client);

async Task waitAction()
{
using (var received = new SemaphoreSlim(0))
{
serverChannel.MessageReceived += message =>
{
Assert.AreEqual("example", message.Bar);
// ReSharper disable once AccessToDisposedClosure
received.Release();
return null;
};

await clientChannel.SendMessageAsync(new Foobar { Bar = "example" }).ConfigureAwait(false);

if (!await received.WaitAsync(10000).ConfigureAwait(false))
throw new TimeoutException("Message was not received in a timely fashion");
}
}

Assert.IsTrue(Task.Run(waitAction).Wait(10000), @"Message was not received in a timely fashion");
}
}

private class Foobar
{
public string Bar;
}

public class ExceptionDuringSetupGameHost : TestRunHeadlessGameHost
{
public ExceptionDuringSetupGameHost(string gameName)
Expand Down
155 changes: 155 additions & 0 deletions osu.Framework.Tests/Platform/IPCTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

#nullable disable

using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Platform;
using osu.Framework.Tests.IO;

namespace osu.Framework.Tests.Platform
{
[TestFixture]
public partial class IPCTest
{
[Test]
public void TestNoPipeNameSpecifiedCoexist()
{
using (var host1 = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPipeName = null }))
using (var host2 = new HeadlessGameHost(@"client", new HostOptions { IPCPipeName = null }))
{
Assert.IsTrue(host1.IsPrimaryInstance, @"Host 1 wasn't able to bind");
Assert.IsTrue(host2.IsPrimaryInstance, @"Host 2 wasn't able to bind");
}
}

[Test]
public void TestDifferentPipeNamesCoexist()
{
using (var host1 = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPipeName = "test-app" }))
using (var host2 = new HeadlessGameHost(@"client", new HostOptions { IPCPipeName = "test-app-2" }))
{
Assert.IsTrue(host1.IsPrimaryInstance, @"Host 1 wasn't able to bind");
Assert.IsTrue(host2.IsPrimaryInstance, @"Host 2 wasn't able to bind");
}
}

[Test]
public void TestOneWay()
{
using (var server = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPipeName = "test-app" }))
using (var client = new HeadlessGameHost(@"client", new HostOptions { IPCPipeName = "test-app" }))
{
Assert.IsTrue(server.IsPrimaryInstance, @"Server wasn't able to bind");
Assert.IsFalse(client.IsPrimaryInstance, @"Client was able to bind when it shouldn't have been able to");

var serverChannel = new IpcChannel<Foobar>(server);
var clientChannel = new IpcChannel<Foobar>(client);

async Task waitAction()
{
using (var received = new SemaphoreSlim(0))
{
serverChannel.MessageReceived += message =>
{
Assert.AreEqual("example", message.Bar);
// ReSharper disable once AccessToDisposedClosure
received.Release();
return null;
};

await clientChannel.SendMessageAsync(new Foobar { Bar = "example" }).ConfigureAwait(false);

if (!await received.WaitAsync(10000).ConfigureAwait(false))
throw new TimeoutException("Message was not received in a timely fashion");
}
}

Assert.IsTrue(Task.Run(waitAction).Wait(10000), @"Message was not received in a timely fashion");
}
}

[Test]
public void TestTwoWay()
{
using (var server = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPipeName = "test-app" }))
using (var client = new HeadlessGameHost(@"client", new HostOptions { IPCPipeName = "test-app" }))
{
Assert.IsTrue(server.IsPrimaryInstance, @"Server wasn't able to bind");
Assert.IsFalse(client.IsPrimaryInstance, @"Client was able to bind when it shouldn't have been able to");

var serverChannel = new IpcChannel<Foobar, Foobar>(server);
var clientChannel = new IpcChannel<Foobar, Foobar>(client);

async Task waitAction()
{
using (var received = new SemaphoreSlim(0))
{
serverChannel.MessageReceived += message =>
{
Assert.AreEqual("example", message.Bar);
// ReSharper disable once AccessToDisposedClosure
received.Release();

return new Foobar { Bar = "test response" };
};

var response = await clientChannel.SendMessageWithResponseAsync(new Foobar { Bar = "example" }).ConfigureAwait(false);

if (!await received.WaitAsync(10000).ConfigureAwait(false))
throw new TimeoutException("Message was not received in a timely fashion");

Assert.That(response?.Bar, Is.EqualTo("test response"));
}
}

Assert.IsTrue(Task.Run(waitAction).Wait(10000), @"Message was not received in a timely fashion");
}
}

[Test]
public void TestIpcLegacyPortSupport()
{
#pragma warning disable CS0618 // Type or member is obsolete
using (var server = new BackgroundGameHeadlessGameHost(@"server", new HostOptions { IPCPort = 45356 }))
using (var client = new HeadlessGameHost(@"client", new HostOptions { IPCPort = 45356 }))
#pragma warning restore CS0618 // Type or member is obsolete
{
Assert.IsTrue(server.IsPrimaryInstance, @"Server wasn't able to bind");
Assert.IsFalse(client.IsPrimaryInstance, @"Client was able to bind when it shouldn't have been able to");

var serverChannel = new IpcChannel<Foobar, object>(server);
var clientChannel = new IpcChannel<Foobar, object>(client);

async Task waitAction()
{
using (var received = new SemaphoreSlim(0))
{
serverChannel.MessageReceived += message =>
{
Assert.AreEqual("example", message.Bar);
// ReSharper disable once AccessToDisposedClosure
received.Release();
return null;
};

await clientChannel.SendMessageAsync(new Foobar { Bar = "example" }).ConfigureAwait(false);

if (!await received.WaitAsync(10000).ConfigureAwait(false))
throw new TimeoutException("Message was not received in a timely fashion");
}
}

Assert.IsTrue(Task.Run(waitAction).Wait(10000), @"Message was not received in a timely fashion");
}
}

private class Foobar
{
public string Bar;
}
}
}
6 changes: 3 additions & 3 deletions osu.Framework.Tests/Platform/UserInputManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public partial class UserInputManagerTest
[Test]
public void IsAliveTest()
{
using (var client = new TestHeadlessGameHost(@"client", 45356))
using (var client = new TestHeadlessGameHost(@"client"))
{
var testGame = new TestTestGame();
client.Run(testGame);
Expand All @@ -25,8 +25,8 @@ private class TestHeadlessGameHost : TestRunHeadlessGameHost
{
public Drawable CurrentRoot => Root;

public TestHeadlessGameHost(string gameName, int? ipcPort)
: base(gameName, new HostOptions { IPCPort = ipcPort })
public TestHeadlessGameHost(string gameName)
: base(gameName, new HostOptions { IPCPipeName = gameName })
{
}
}
Expand Down
18 changes: 14 additions & 4 deletions osu.Framework/HostOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Globalization;
using osu.Framework.Platform;

namespace osu.Framework
Expand All @@ -11,12 +13,20 @@ namespace osu.Framework
public class HostOptions
{
/// <summary>
/// The IPC port to bind. This port should be between 1024 and 49151,
/// should be shared by all instances of a given osu!framework app,
/// but be distinct from IPC ports specified by other osu!framework apps.
/// Use <see cref="IPCPipeName"/> instead.
/// </summary>
[Obsolete("Use IPCPipeName instead.")] // can be removed 20250603.
public int? IPCPort
{
set => IPCPipeName = value?.ToString(CultureInfo.InvariantCulture);
}

/// <summary>
/// The IPC pipe name to bind. This should be shared by all instances of
/// an osu!framework app that want to perform inter-process communications.
/// See <see cref="IIpcHost"/> for more details on usage.
/// </summary>
public int? IPCPort { get; set; }
public string? IPCPipeName { get; set; }

/// <summary>
/// Whether this is a portable installation. Will cause all game files to be placed alongside the executable, rather than in the standard data directory.
Expand Down
19 changes: 13 additions & 6 deletions osu.Framework/Platform/DesktopGameHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ namespace osu.Framework.Platform
{
public abstract class DesktopGameHost : SDLGameHost
{
private TcpIpcProvider ipcProvider;
private readonly int? ipcPort;
private NamedPipeIpcProvider ipcProvider;
private readonly string ipcPipeName;

protected DesktopGameHost(string gameName, HostOptions options = null)
: base(gameName, options)
{
ipcPort = Options.IPCPort;
ipcPipeName = Options.IPCPipeName;
IsPortableInstallation = Options.PortableInstallation;
}

Expand Down Expand Up @@ -55,13 +55,13 @@ protected override void SetupForRun()

private void ensureIPCReady()
{
if (ipcPort == null)
if (ipcPipeName == null)
return;

if (ipcProvider != null)
return;

ipcProvider = new TcpIpcProvider(ipcPort.Value);
ipcProvider = new NamedPipeIpcProvider(ipcPipeName);
ipcProvider.MessageReceived += OnMessageReceived;

IsPrimaryInstance = ipcProvider.Bind();
Expand Down Expand Up @@ -97,7 +97,7 @@ public override bool PresentFileExternally(string filename)
return true;
}

private void openUsingShellExecute(string path) => Process.Start(new ProcessStartInfo
private static void openUsingShellExecute(string path) => Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true //see https://github.com/dotnet/corefx/issues/10361
Expand All @@ -110,6 +110,13 @@ public override Task SendMessageAsync(IpcMessage message)
return ipcProvider.SendMessageAsync(message);
}

public override Task<IpcMessage> SendMessageWithResponseAsync(IpcMessage message)
{
ensureIPCReady();

return ipcProvider.SendMessageWithResponseAsync(message);
}

protected override void Dispose(bool isDisposing)
{
ipcProvider?.Dispose();
Expand Down
1 change: 1 addition & 0 deletions osu.Framework/Platform/GameHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public abstract class GameHost : IIpcHost, IDisposable
protected IpcMessage OnMessageReceived(IpcMessage message) => MessageReceived?.Invoke(message);

public virtual Task SendMessageAsync(IpcMessage message) => throw new NotSupportedException("This platform does not implement IPC.");
public virtual Task<IpcMessage> SendMessageWithResponseAsync(IpcMessage message) => throw new NotSupportedException("This platform does not implement IPC.");

/// <summary>
/// Requests that a file or folder be opened externally with an associated application, if available.
Expand Down
11 changes: 9 additions & 2 deletions osu.Framework/Platform/IIpcHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ public interface IIpcHost
/// Invoked when a message is received by this IPC server.
/// Returns either a response in the form of an <see cref="IpcMessage"/>, or <c>null</c> for no response.
/// </summary>
event Func<IpcMessage, IpcMessage> MessageReceived;
event Func<IpcMessage, IpcMessage?>? MessageReceived;

/// <summary>
/// Send a message to the IPC server.
/// Asynchronously send a message to the IPC server.
/// </summary>
/// <param name="ipcMessage">The message to send.</param>
Task SendMessageAsync(IpcMessage ipcMessage);

/// <summary>
/// Asynchronously send a message to the IPC server and receive a response.
/// </summary>
/// <param name="message">The message to send.</param>
/// <returns>The response from the server.</returns>
Task<IpcMessage?> SendMessageWithResponseAsync(IpcMessage message);
}
}
Loading

0 comments on commit 2e19f17

Please sign in to comment.