From d93595460951830b18b698b3a5aa3566f6ffaf2e Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Wed, 1 Jul 2026 11:40:46 +0200 Subject: [PATCH] .NET: add IpcContext ambient context (POCO callback-capable contracts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `IpcContext` with a static `IpcContext? Current` backed by AsyncLocal, published for the duration of a server-side handler (and callback) invocation in `Server.MethodCall`. It exposes the peer (`Client`) + the call's `CancellationToken` and a `GetCallback()` that mirrors `Message.Client.GetCallback()` — so a service-contract implementation can reach callbacks WITHOUT a `Message` parameter, letting the contract-defining assembly stay free of a UiPath.Ipc reference. Additive and non-breaking: `Message` injection is unchanged; `Current` is null outside a call and composes across nested calls (a callback serviced mid-call). Tests (xUnit, self-contained POCO contract with no `Message` param): `Current` is null outside a call and after it completes, set while honoring a call, and a Message-free contract reaches its callback purely via `IpcContext.Current.GetCallback()`. Builds on net461/net6.0/net6.0-windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/UiPath.CoreIpc.Tests/IpcContextTests.cs | 107 ++++++++++++++++++++ src/UiPath.CoreIpc/IpcContext.cs | 62 ++++++++++++ src/UiPath.CoreIpc/Server/Server.cs | 24 +++-- 3 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 src/UiPath.CoreIpc.Tests/IpcContextTests.cs create mode 100644 src/UiPath.CoreIpc/IpcContext.cs diff --git a/src/UiPath.CoreIpc.Tests/IpcContextTests.cs b/src/UiPath.CoreIpc.Tests/IpcContextTests.cs new file mode 100644 index 00000000..d1fae123 --- /dev/null +++ b/src/UiPath.CoreIpc.Tests/IpcContextTests.cs @@ -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 ReachCallbackViaContext(); + Task ContextIsSet(); +} + +public interface IContextProbeCallback +{ + Task 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 ReachCallbackViaContext() + => IpcContext.Current!.GetCallback().Pong(); + + public Task ContextIsSet() => Task.FromResult(IpcContext.Current is not null); +} + +public sealed class ContextProbeCallback : IContextProbeCallback +{ + public Task 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 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() + .BuildServiceProvider(), + }; + + var client = new IpcClient + { + Transport = new NamedPipeClientTransport { PipeName = pipeName }, + Callbacks = new() { { typeof(IContextProbeCallback), new ContextProbeCallback() } }, + }; + var proxy = client.GetProxy(); + + 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(); + } + } +} diff --git a/src/UiPath.CoreIpc/IpcContext.cs b/src/UiPath.CoreIpc/IpcContext.cs new file mode 100644 index 00000000..c5e604fe --- /dev/null +++ b/src/UiPath.CoreIpc/IpcContext.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading; + +namespace UiPath.Ipc; + +/// Ambient context of the IPC call being honored, letting a POCO contract reach +/// the peer without a parameter. Coexists with . +public sealed class IpcContext +{ + private static readonly AsyncLocal CurrentContext = new(); + + /// The call in progress on the current async flow, or null if there is none. + public static IpcContext? Current => CurrentContext.Value; + + internal IpcContext(IClient? client, CancellationToken cancellationToken) + { + Client = client; + CancellationToken = cancellationToken; + } + + /// The peer of the in-flight call (same handle as ), + /// or null when the current endpoint has no reachable peer. + public IClient? Client { get; } + + /// The cancellation token of the in-flight call. + public CancellationToken CancellationToken { get; } + + /// Equivalent to Message.Client.GetCallback<TCallback>(). + /// is null. + public TCallback GetCallback() 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(); + + /// Publishes as for the returned + /// scope, restoring the previous value on dispose so nested calls compose. + 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; + } + } +} diff --git a/src/UiPath.CoreIpc/Server/Server.cs b/src/UiPath.CoreIpc/Server/Server.cs index e957d43f..34f2ab49 100644 --- a/src/UiPath.CoreIpc/Server/Server.cs +++ b/src/UiPath.CoreIpc/Server/Server.cs @@ -167,21 +167,23 @@ async ValueTask InvokeMethod() Task ScheduleMethodCall() => defaultScheduler ? MethodCall() : RunOnScheduler(); async Task 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 RunOnScheduler() => Task.Factory.StartNew(MethodCall, cancellationToken, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();