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
107 changes: 107 additions & 0 deletions src/UiPath.CoreIpc.Tests/IpcContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using UiPath.Ipc.Transport.NamedPipe;

namespace UiPath.Ipc.Tests;

// A POCO contract: no `Message` parameter, yet callback-capable via `IpcContext.Current`.
public interface IContextProbe
{
Task<string> ReachCallbackViaContext();
Task<bool> ContextIsSet();
}

public interface IContextProbeCallback
{
Task<string> Pong();
}

public sealed class ContextProbe : IContextProbe
{
public const string PongValue = "pong-from-callback";

// No Message parameter anywhere: the peer is reached through the ambient context.
public Task<string> ReachCallbackViaContext()
=> IpcContext.Current!.GetCallback<IContextProbeCallback>().Pong();

@eduard-dumitru eduard-dumitru Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mihaipetrisor82uip:
https://github.com/UiPath/coreipc/pull/127/changes#r3675063208

In the Ipc codebase itself, it's only consumed in unit tests.


public Task<bool> ContextIsSet() => Task.FromResult(IpcContext.Current is not null);
}

public sealed class ContextProbeCallback : IContextProbeCallback
{
public Task<string> Pong() => Task.FromResult(ContextProbe.PongValue);
}

public sealed class IpcContextTests
{
[Fact]
public void Current_IsNull_OutsideAnyIpcCall()
=> IpcContext.Current.ShouldBeNull();

[Fact]
public async Task Current_IsSet_WhileHonoringACall()
{
await using var pair = await Pair.Create();
(await pair.Proxy.ContextIsSet()).ShouldBeTrue();
}

[Fact]
public async Task PocoContract_ReachesCallback_ViaIpcContext()
{
await using var pair = await Pair.Create();
(await pair.Proxy.ReachCallbackViaContext()).ShouldBe(ContextProbe.PongValue);
}

[Fact]
public async Task Current_IsNullAgain_AfterTheCallCompletes()
{
await using var pair = await Pair.Create();
await pair.Proxy.ContextIsSet();
// The ambient value must not leak into the test's own async flow.
IpcContext.Current.ShouldBeNull();
}

private sealed class Pair : IAsyncDisposable
{
private readonly IpcServer _server;
public IContextProbe Proxy { get; }

private Pair(IpcServer server, IContextProbe proxy)
{
_server = server;
Proxy = proxy;
}

public static async Task<Pair> Create()
{
var pipeName = $"ipctest_ctx_{Guid.NewGuid():N}";

var server = new IpcServer
{
Transport = new NamedPipeServerTransport { PipeName = pipeName },
Endpoints = new() { typeof(IContextProbe) },
ServiceProvider = new ServiceCollection()
.AddLogging()
.AddSingleton<IContextProbe, ContextProbe>()
.BuildServiceProvider(),
};

var client = new IpcClient
{
Transport = new NamedPipeClientTransport { PipeName = pipeName },
Callbacks = new() { { typeof(IContextProbeCallback), new ContextProbeCallback() } },
};
var proxy = client.GetProxy<IContextProbe>();

server.Start();
await Task.Yield();
return new Pair(server, proxy);
}

public async ValueTask DisposeAsync()
{
(Proxy as IpcProxy)?.Dispose();
await ((Proxy as IpcProxy)?.CloseConnection() ?? default);
await _server.DisposeAsync();
}
}
}
62 changes: 62 additions & 0 deletions src/UiPath.CoreIpc/IpcContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Threading;

namespace UiPath.Ipc;

/// <summary>Ambient context of the IPC call being honored, letting a POCO contract reach
/// the peer without a <see cref="Message"/> parameter. Coexists with <see cref="Message"/>.</summary>
public sealed class IpcContext

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is IpcContext consumed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
private static readonly AsyncLocal<IpcContext?> CurrentContext = new();

/// <summary>The call in progress on the current async flow, or null if there is none.</summary>
public static IpcContext? Current => CurrentContext.Value;

internal IpcContext(IClient? client, CancellationToken cancellationToken)
{
Client = client;
CancellationToken = cancellationToken;
}

/// <summary>The peer of the in-flight call (same handle as <see cref="Message.Client"/>),
/// or null when the current endpoint has no reachable peer.</summary>
public IClient? Client { get; }

/// <summary>The cancellation token of the in-flight call.</summary>
public CancellationToken CancellationToken { get; }

/// <summary>Equivalent to <c>Message.Client.GetCallback&lt;TCallback&gt;()</c>.</summary>
/// <exception cref="InvalidOperationException"><see cref="Client"/> is null.</exception>
public TCallback GetCallback<TCallback>() where TCallback : class
=> (Client ?? throw new InvalidOperationException(
$"{nameof(IpcContext)}.{nameof(Current)} has no peer client; " +
$"{nameof(GetCallback)} is only available while honoring a server-side IPC call."))
.GetCallback<TCallback>();

/// <summary>Publishes <paramref name="context"/> as <see cref="Current"/> for the returned
/// scope, restoring the previous value on dispose so nested calls compose.</summary>
internal static IDisposable Push(IpcContext context)
{
var previous = CurrentContext.Value;
CurrentContext.Value = context;
return new Scope(previous);
}

private sealed class Scope : IDisposable
{
private readonly IpcContext? _previous;
private bool _disposed;

public Scope(IpcContext? previous) => _previous = previous;

public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
CurrentContext.Value = _previous;
}
}
}
24 changes: 13 additions & 11 deletions src/UiPath.CoreIpc/Server/Server.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,21 +167,23 @@ async ValueTask<Response> InvokeMethod()
Task<object?> ScheduleMethodCall() => defaultScheduler ? MethodCall() : RunOnScheduler();
async Task<object?> MethodCall()
{
await (route.BeforeCall?.Invoke(
new CallInfo(newConnection: false, method.MethodInfo, arguments),
cancellationToken) ?? Task.CompletedTask);
// So a POCO contract can reach the peer without a Message parameter.
using (IpcContext.Push(new IpcContext(_client, cancellationToken)))
{
await (route.BeforeCall?.Invoke(
new CallInfo(newConnection: false, method.MethodInfo, arguments),
cancellationToken) ?? Task.CompletedTask);

Task invocationTask = null!;
Task invocationTask = method.Invoke(service, arguments, cancellationToken);
await invocationTask;

invocationTask = method.Invoke(service, arguments, cancellationToken);
await invocationTask;
if (!returnTaskType.IsGenericType)
{
return null;
}

if (!returnTaskType.IsGenericType)
{
return null;
return GetTaskResult(returnTaskType, invocationTask);
}

return GetTaskResult(returnTaskType, invocationTask);
}

Task<object?> RunOnScheduler() => Task.Factory.StartNew(MethodCall, cancellationToken, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
Expand Down
Loading