Add IpcContext ambient context (.NET, Python) + TS AbortSignal support#127
Open
eduard-dumitru wants to merge 7 commits into
Open
Add IpcContext ambient context (.NET, Python) + TS AbortSignal support#127eduard-dumitru wants to merge 7 commits into
eduard-dumitru wants to merge 7 commits into
Conversation
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<T>()` that mirrors `Message.Client.GetCallback<T>()` — 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<T>()`. Builds on net461/net6.0/net6.0-windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntracts) Python counterpart of the .NET IpcContext. `IpcContext.Current` (a metaclass property over a task-local ContextVar) is non-None exactly while a handler is being honored, and exposes `get_callback(contract)` — so a POCO handler reaches the peer without a `Message` parameter, and the contract module need not import uipath_ipc. Published in `IpcConnection._invoke_callback` via `IpcContext._activate(self)`; no explicit reset is needed because the dispatch runs in its own asyncio task, whose contextvars copy is task-local (never leaks to the receive loop or sibling tasks, dropped when the task ends). Additive and non-breaking: `Message` injection is unchanged. Exported from the package root. Tests (real Python↔Python pair over TCP loopback): Current is None outside a call and doesn't leak into the caller's task, is set while honoring a call, and a Message-free POCO service reaches the client's callback purely via IpcContext.Current.get_callback. Full unit suite: 241 passed, 26 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A contract method may now pass a Web/Node AbortSignal in place of the TS CancellationToken clone. RpcRequestFactory routes each argument through AbortSignalAdapter.ensureCancellationToken (check-and-adapt in one gulp: passes a CancellationToken through, bridges an AbortSignal, else undefined) and substitutes the result in-place, so the wire form AND the ending-CancellationToken handling are byte-identical to passing a CancellationToken. Purely additive: CancellationToken / CancellationTokenSource are unchanged; no wire change. The bridge (toCancellationToken) drives a CancellationTokenSource from the signal's 'abort' (immediately if already aborted) and disposes it once it fires; the source is created with no cancelAfter delay, so it holds no timer (dispose is belt-and-suspenders) and the 'abort' listener is registered `once`. Tests (Jasmine): isAbortSignal recognition; live signal cancels + fires registrations on abort; already-aborted -> already-cancelled; ensureCancellationToken passes a token through, adapts a signal, returns undefined otherwise. tsc (src + test) clean; 7/7 specs pass. Note: TS IpcContext (POCO callback contracts) and callee-side AbortSignal are intentionally NOT included — the js client has no callee cancellation at all (RpcCallContext.Incomming carries no CancellationToken; TS doesn't send/observe cancel frames, a known parity gap) and no handler-side reach-back (no getCallback; Message carries no Client). Both are net-new features, deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a12533f to
c549985
Compare
A callback the peer invokes on a TS client can now be cancelled. Previously an inbound CancellationRequest frame hit a `Method not implemented.` stub in RpcChannel and was swallowed-and-logged, so the running handler never learned of the cancel. - RpcChannel now tracks each in-flight incoming call in an IncomingCallTable (a per-call CancellationTokenSource keyed by request id) and implements processIncommingCancellationRequest to cancel the matching call; in-flight calls are also cancelled when the channel is disposed. RpcCallContext.Incomming carries the per-call token (defaulted to none, so existing sites are unaffected). - ChannelManager.invokeCallback injects that token into the handler when the callback contract declares a trailing cancellation parameter: a live CancellationToken, or a bridged AbortSignal (via AbortSignalAdapter.toAbortSignal) when the contract asks for one. This is metadata-driven and unambiguous — it fires only for a registered (decorated) contract, resolved by endpoint name via the new ContractStore.maybeGetByEndpoint. Callbacks registered by bare endpoint name carry no parameter-type metadata and the empty-string wire form of a cancellation slot is indistinguishable from a real empty string, so absent metadata the arguments are left untouched. Fully additive; no breaking changes. Adds unit tests for IncomingCallTable, the RpcChannel frame->cancel path, the invokeCallback injection (CancellationToken, AbortSignal, and the no-metadata no-op), and AbortSignalAdapter.toAbortSignal; updates LIMITATIONS.md. 381 std specs pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rames) Closes the other half of the cancellation parity gap. A fired CancellationToken used to only reject the local awaiting promise; the peer never learned of it and ran the operation to completion. RpcChannel.call now sends a CancellationRequest frame when the token fires, mirroring .NET's Connection.RemoteCall — so the remote actually stops. - registerOutgoingCancellation arms a token registration (before sending, like .NET) that fire-and-forgets a CancellationRequest for the request id; it is disposed when the call settles, so it never fires for a completed call nor retains the token. Guarded by canBeCanceled, so CancellationToken.none is a no-op and untimed/untokened calls are unchanged. Two issues found by an adversarial review of the above and fixed here: - A token ALREADY cancelled at call time synchronously fires the registration, which (with no send lock) enqueued the cancel frame before the request frame — the peer dropped the orphan cancel and ran the request uncancelled. Fixed by suppressing the request entirely when ct.isCancellationRequested, matching .NET (a call abandoned before it is sent never reaches the wire). For a not-yet- cancelled token, register() only stores the callback, so any later cancel is necessarily enqueued after the request. - disposeAsync never settled pending outgoing calls, so a call parked at `await promise` under the default infinite timeout hung forever after the channel died, and the (channel-capturing) cancellation registration leaked on the caller's token. Added OutgoingCallTable.completeAll (mirroring .NET's Connection.CompleteRequests) to fault pending calls with an ObjectDisposedError, which unblocks the await, runs call()'s finally, and releases the registration. A no-throw guard on the pending-call promise prevents an unhandled rejection when a call is cancelled/disposed while still connecting. Adds unit tests (RpcChannelSendCancellation): fired-token propagation with Request-before-Cancel ordering (incl. cancel-during-connect), already-cancelled suppression, non-cancelable no-op, no-cancel-after-completion, and dispose-settles-and-releases. Updates LIMITATIONS.md. 387 std specs pass; the implementation was verified by two rounds of independent adversarial review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Passing a real CancellationToken (or an AbortSignal) to a proxy method threw
`TypeError: Converting circular structure to JSON` before anything was sent: the
argument serializer (Converter) ran JSON.stringify over the raw token, and a live
CancellationTokenSource token is a circular graph (token -> source -> token).
CancellationToken.none serializes to `{}`, so this was never hit — no existing
test passed a live token as a proxy argument — but it made caller-side
cancellation unusable in practice (you could not even issue the call).
RpcRequestFactory now keeps the live token for cancellation (local binding + the
CancellationRequest frame) but writes an inert placeholder (CancellationToken.none
-> `{}`) into the wire slot. The cancellation signal is out-of-band and the
receiver ignores the slot's content for a CancellationToken parameter, so this is
purely a serialization fix — no behavioural change to a call that never cancels.
This is a pre-existing bug (present on master), independent of the recent
callee/caller cancellation work; it was surfaced by wiring up a real end-to-end
cancellation call. Adds unit tests for a live CancellationToken, an AbortSignal,
and the no-argument case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the real-connection test that the mock-based unit tests could not provide: the TypeScript client (over a live NamedPipe/WebSocket connection to the real .NET NodeInterop server) cancels an in-flight call, and we verify the .NET handler actually observes it. - .NET server (Contracts + ServiceImpls): IAlgebra gains WaitForCancellation(ct), which parks on its injected CancellationToken until cancelled, and CancellationCount(), which reports how many cancellations it has observed. The single-parameter WaitForCancellation aligns the ct at position 0, so a TS `WaitForCancellation(cts.token)` call maps cleanly (the server injects the per-request token; the wire slot is ignored). - TS contract (test IAlgebra) mirrors the two methods. - end-to-end.test.ts: calls WaitForCancellation(token), cancels, asserts the caller's promise rejects locally with OperationCanceledError AND — the point — polls CancellationCount until the server-side count increments, which only happens if the client transmits a CancellationRequest frame. Runs for both the WebSocket and NamedPipe transports (delta-asserted against the shared server). This test only passes with the caller-side cancel-frame sending and the CancellationToken-argument serialization fix; verified green against the real .NET server over a named pipe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
POCO-friendly, callback-capable IPC contracts. Today a strongly-typed contract that wants to service callbacks is forced to reference
UiPath.Ipc(for theMessageparameter). This PR adds an ambient per-operation context,IpcContext, whose staticIpcContext.Currentis non-null exactly while a call is being honored — so a contract implementation reaches the peer viaIpcContext.Current.GetCallback<T>()without aMessageparameter, and the contract-defining assembly can stay free of aUiPath.Ipcreference.Additionally, on TypeScript a contract may now use a Web/Node
AbortSignalanywhere aCancellationTokenis accepted.Everything is additive and non-breaking:
Message,CancellationToken, and the wire format are unchanged. One commit per language/runtime.Commits
286822d) —IpcContext(public sealed class, staticIpcContext? CurrentviaAsyncLocal), published around the handler invocation inServer.MethodCall(covers inbound calls and callbacks; composes across nested calls; null outside a call). ExposesClient,CancellationToken, andGetCallback<T>()(same machinery asMessage.Client.GetCallback<T>()). Builds net461 / net6.0 / net6.0-windows.042c806) —IpcContextwithIpcContext.Current(a metaclass property over a task-localContextVar) +get_callback(contract), activated inIpcConnection._invoke_callback. No explicit reset needed: dispatch runs in its own asyncio task (task-local contextvars), so the value never leaks and is dropped when the task ends.a12533f) — acceptAbortSignalwherever aCancellationTokenis:RpcRequestFactorydetects it during arg scanning and substitutes a bridgedCancellationTokenin-place (newAbortSignalAdapter), so the wire form and ending-CancellationTokenhandling are byte-identical. No wire change.Tests
Message):Currentis null outside/after a call, set while honoring, and a Message-free contract reaches its callback viaIpcContext.Current.GetCallback.AbortSignalAdapter(recognition; live signal cancels + fires registrations; already-aborted → already-cancelled).tsc(src + test) clean.Not included (intentional)
TS
IpcContextis deferred. The js client has no handler-side reach-back today (nogetCallback;Messagecarries noClient; dispatch injects nothing), soIpcContext.getCallbackthere would require net-new server→peer callback machinery — a separate feature, out of scope for this additive PR.🤖 Generated with Claude Code