Skip to content

Add IpcContext ambient context (.NET, Python) + TS AbortSignal support#127

Open
eduard-dumitru wants to merge 7 commits into
masterfrom
feature/ipc-context
Open

Add IpcContext ambient context (.NET, Python) + TS AbortSignal support#127
eduard-dumitru wants to merge 7 commits into
masterfrom
feature/ipc-context

Conversation

@eduard-dumitru

Copy link
Copy Markdown
Collaborator

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 the Message parameter). This PR adds an ambient per-operation context, IpcContext, whose static IpcContext.Current is non-null exactly while a call is being honored — so a contract implementation reaches the peer via IpcContext.Current.GetCallback<T>() without a Message parameter, and the contract-defining assembly can stay free of a UiPath.Ipc reference.

Additionally, on TypeScript a contract may now use a Web/Node AbortSignal anywhere a CancellationToken is accepted.

Everything is additive and non-breaking: Message, CancellationToken, and the wire format are unchanged. One commit per language/runtime.

Commits

  • .NET (286822d) — IpcContext (public sealed class, static IpcContext? Current via AsyncLocal), published around the handler invocation in Server.MethodCall (covers inbound calls and callbacks; composes across nested calls; null outside a call). Exposes Client, CancellationToken, and GetCallback<T>() (same machinery as Message.Client.GetCallback<T>()). Builds net461 / net6.0 / net6.0-windows.
  • Python (042c806) — IpcContext with IpcContext.Current (a metaclass property over a task-local ContextVar) + get_callback(contract), activated in IpcConnection._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.
  • TypeScript (a12533f) — accept AbortSignal wherever a CancellationToken is: RpcRequestFactory detects it during arg scanning and substitutes a bridged CancellationToken in-place (new AbortSignalAdapter), so the wire form and ending-CancellationToken handling are byte-identical. No wire change.

Tests

  • .NET — 4 xUnit tests via a self-contained POCO contract (no Message): Current is null outside/after a call, set while honoring, and a Message-free contract reaches its callback via IpcContext.Current.GetCallback.
  • Python — 3 tests over a real TCP-loopback pair (mirrors the .NET end-to-end); full unit suite 241 passed, no regressions.
  • TypeScript — 3 Jasmine specs for AbortSignalAdapter (recognition; live signal cancels + fires registrations; already-aborted → already-cancelled). tsc (src + test) clean.

Not included (intentional)

TS IpcContext is deferred. The js client has no handler-side reach-back today (no getCallback; Message carries no Client; dispatch injects nothing), so IpcContext.getCallback there would require net-new server→peer callback machinery — a separate feature, out of scope for this additive PR.

🤖 Generated with Claude Code

eduard-dumitru and others added 3 commits July 1, 2026 11:40
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>
eduard-dumitru and others added 4 commits July 1, 2026 14:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant