Skip to content

[ROBO-5858] Python IpcContext + TypeScript AbortSignal and cancellation propagation - #148

Draft
eduard-dumitru wants to merge 8 commits into
masterfrom
feature/py-context-ts-cancellation
Draft

[ROBO-5858] Python IpcContext + TypeScript AbortSignal and cancellation propagation#148
eduard-dumitru wants to merge 8 commits into
masterfrom
feature/py-context-ts-cancellation

Conversation

@eduard-dumitru

@eduard-dumitru eduard-dumitru commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Jira: ROBO-5858

What & why

Split out of #127 (ROBO-5857), which is now .NET-only. This PR carries the Python and TypeScript concerns so each runtime can be reviewed on its own.

Python — ambient IpcContext

The counterpart of the .NET ambient context: IpcContext.Current (a metaclass property over a task-local ContextVar) plus get_callback(contract), activated in IpcConnection._invoke_callback. A POCO handler reaches the peer without a Message parameter, so a contract module need not import from uipath_ipc.

No explicit reset is needed: dispatch runs in its own asyncio task, whose contextvars copy is task-local — the value never leaks to the receive loop or sibling handler tasks and is dropped when the task ends.

TypeScript — AbortSignal acceptance and cancellation propagation

  • AbortSignal anywhere a CancellationToken is accepted. RpcRequestFactory detects one while scanning arguments and substitutes a bridged CancellationToken in place (new AbortSignalAdapter), so the wire form and trailing-CancellationToken handling are byte-identical.
  • Callee side — a hosted callback now honors an inbound cancel. New IncomingCallTable tracks in-flight incoming calls by request id; the running handler's per-call token fires, and when the callback contract declares a trailing CancellationToken/AbortSignal, the live token (or a bridged signal) is injected. Injection is metadata-driven: a callback registered by bare endpoint name carries no parameter types, so its arguments are left untouched.
  • Caller side — a fired token now sends a CancellationRequest to the peer, so the remote actually stops instead of running to completion. A token already cancelled before the call suppresses the request entirely (the remote never runs it), mirroring .NET.
  • Fix — serializing a live CancellationToken proxy argument (a circular token → source → token graph JSON can't handle) no longer reaches the converter.

This closes the parity gap documented in LIMITATIONS.md, which is updated accordingly.

No wire-format change: no new frame types, and no change to existing frame layouts. TypeScript now emits a CancellationRequest — a frame type .NET and Python already send and both peers already understand — where it previously sent nothing.

Tests

  • Python — 3 tests over a real TCP-loopback pair, mirroring the .NET end-to-end shape. Full suite: 263 passed, 4 skipped.
  • TypeScript unit — specs for AbortSignalAdapter, IncomingCallTable, RpcChannelCancellation, RpcChannelSendCancellation, CallbackCancellation, and RpcRequestFactoryCancellation.
  • No-mock TS↔.NET end-to-end — caller-side cancellation reaching a .NET callee, and a server-initiated cancel observed by a TS-hosted callback, each proven interchangeably with a CancellationToken and an AbortSignal. Needs the matching UiPath.CoreIpc.NodeInterop harness additions included here.
  • tsc --noEmit (src + test) clean.

⚠️ The JS interop suite (npm test) was not run locally — it needs the .NET host process. CI should be the gate on that.

🤖 Generated with Claude Code

eduard-dumitru and others added 8 commits July 29, 2026 14:50
…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>
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>
Extends the real-connection e2e suite so every new TypeScript cancellation
feature is exercised against the live .NET NodeInterop server, and shows that a
TypeScript contract may use a CancellationToken or an AbortSignal interchangeably
against the same C# CancellationToken counterpart — as caller AND as callee.

Caller (TS -> .NET service):
- WaitForCancellation now accepts CancellationToken | AbortSignal. A new case
  passes an AbortSignal (bridged to a CancellationToken on the wire) alongside the
  existing CancellationToken case; both make the .NET handler observe the cancel.

Callee (.NET service -> TS-hosted callback):
- New .NET ICancellationCallback { Wait(ct) } plus IAlgebra.CancelCallback, which
  invokes the callback on the client and then cancels it.
- Two new cases register a TS handler whose Wait parameter is a CancellationToken
  in one and an AbortSignal in the other (contract metadata stamped, mirroring
  emitDecoratorMetadata); each asserts the handler's injected token/signal fires
  when the .NET server cancels, and that the server sees the callback complete.

Verified green for all four (caller/callee x CancellationToken/AbortSignal)
against the real .NET server over a named pipe. Unit suite: 390 specs, 0 failures.

Note: callee-side injection is metadata-driven, so it requires a callback contract
whose parameter types are available at runtime (emitDecoratorMetadata or an
equivalent stamp); a bare-endpoint callback receives no injected cancellation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment/docstring text only — no code, behavior, or wire change. `tsc --noEmit`
clean; Python suite 263 passed, 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eduard-dumitru eduard-dumitru changed the title Feature/py context + ts cancellation [ROBO-5858] Python IpcContext + TypeScript AbortSignal and cancellation propagation Jul 29, 2026
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