[ROBO-5858] Python IpcContext + TypeScript AbortSignal and cancellation propagation - #148
Draft
eduard-dumitru wants to merge 8 commits into
Draft
[ROBO-5858] Python IpcContext + TypeScript AbortSignal and cancellation propagation#148eduard-dumitru wants to merge 8 commits into
eduard-dumitru wants to merge 8 commits into
Conversation
…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>
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.
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
IpcContextThe counterpart of the .NET ambient context:
IpcContext.Current(a metaclass property over a task-localContextVar) plusget_callback(contract), activated inIpcConnection._invoke_callback. A POCO handler reaches the peer without aMessageparameter, so a contract module need not import fromuipath_ipc.No explicit reset is needed: dispatch runs in its own asyncio task, whose
contextvarscopy is task-local — the value never leaks to the receive loop or sibling handler tasks and is dropped when the task ends.TypeScript —
AbortSignalacceptance and cancellation propagationAbortSignalanywhere aCancellationTokenis accepted.RpcRequestFactorydetects one while scanning arguments and substitutes a bridgedCancellationTokenin place (newAbortSignalAdapter), so the wire form and trailing-CancellationTokenhandling are byte-identical.IncomingCallTabletracks in-flight incoming calls by request id; the running handler's per-call token fires, and when the callback contract declares a trailingCancellationToken/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.CancellationRequestto 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.CancellationTokenproxy argument (a circulartoken → source → tokengraph 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
AbortSignalAdapter,IncomingCallTable,RpcChannelCancellation,RpcChannelSendCancellation,CallbackCancellation, andRpcRequestFactoryCancellation.CancellationTokenand anAbortSignal. Needs the matchingUiPath.CoreIpc.NodeInteropharness additions included here.tsc --noEmit(src + test) clean.npm test) was not run locally — it needs the .NET host process. CI should be the gate on that.🤖 Generated with Claude Code