[ROBO-5722] Add Python client and server for UiPath.Ipc#125
[ROBO-5722] Add Python client and server for UiPath.Ipc#125eduard-dumitru wants to merge 90 commits into
Conversation
Implements a Python port of the CoreIpc framework, wire-compatible with the .NET server/client. Includes RPC server and client with TCP and Named Pipe transports, asyncio-based, zero mandatory deps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add playground/interop_with_dotnet.py that starts the .NET IpcSample.ConsoleServer and calls IComputingService + ISystemService from the Python client over named pipes. Fix named pipe client to use ProactorEventLoop's native pipe I/O instead of blocking win32file calls in an executor, which deadlocked on concurrent read/write with a non-overlapped handle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Preserves the prior client+server sketch as a reference while we rebuild the Python client from scratch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty package skeleton (pyproject.toml + README + __init__) ready for the phased port. Built with hatchling, requires Python >= 3.10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add pytest as a dev extra in pyproject.toml + [tool.pytest.ini_options] - Configure VS pyproj to use pytest as the test framework - Add tests/ folder with a smoke test that verifies the package imports - Update CoreIpc.sln to drop the old Py projects and add uipath-ipc Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the explicit <Compile> and <Folder> lists with a single **\*.py glob (excluding .venv, __pycache__, build, dist, egg-info). Manage files via the filesystem to keep the glob intact; PTVS will rewrite the entry on UI-driven Add/Remove. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces uipath_ipc.wire with the four wire message types (Request, Response, CancellationRequest, Error) and the MessageType enum. All DTOs are frozen+slotted dataclasses with explicit to_dict/from_dict (snake_case <-> PascalCase) and to_json/from_json convenience methods. Covers the .NET wire-format gotcha that Request.Parameters is a list of *already JSON-encoded* strings, one per argument. 14 tests in tests/wire/test_messages.py verify round-tripping and match captured .NET-shape JSON payloads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-byte header (uint8 MessageType + int32 LE PayloadLength) + payload. read_frame and write_frame operate against asyncio.StreamReader and a structural FrameWriter protocol (any object with write/drain). Also pulls in pytest-asyncio as a dev extra and enables asyncio_mode=auto so async test funcs run without per-test markers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-platform: \<server>\pipe\<name> on Windows (via ProactorEventLoop's create_pipe_connection), /tmp/CoreFxPipe_<name> Unix Domain Socket on POSIX (matches .NET's NamedPipeClient cross-platform convention). ClientTransport ABC abstracts the connect step, returning an (asyncio.StreamReader, asyncio.StreamWriter) pair that downstream layers (connection, proxy) consume. Transport instances are frozen+slotted dataclasses; each connect() opens a fresh stream. Top-level package re-exports ClientTransport + NamedPipeClientTransport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps one (StreamReader, StreamWriter) pair with a background receive loop that decodes frames and resolves pending response futures by Request.id. send_request(req) sends a Request frame and awaits the matching Response. Supports `async with` for lifecycle management. Failure modes covered: underlying-stream close fails all in-flight futures; send on a closed connection raises ConnectionError. Exception translation (Error -> RemoteException) and cancellation forwarding (CancellationRequest on caller cancel) are deferred to Phase C. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcClient lazily opens an IpcConnection over the configured ClientTransport; reused across calls. get_proxy(contract) returns a proxy that satisfies the contract type. __getattr__ on the proxy intercepts method access — each call json.dumps each positional arg into Request.Parameters, sends the Request, awaits the matching Response, json.loads(Data) for non-null Data (or returns None). Server-returned Errors raise RemoteException (placeholder — Phase C.1 will refine the exception model: chain, type-name mapping). Keyword arguments are not supported (.NET wire is positional only); unknown method names raise AttributeError up front. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RemoteException now exposes message, type_name, stack_trace, and inner as first-class attributes. from_error(error) walks the nested wire Error chain producing a matching RemoteException chain, and sets __cause__ so Python tracebacks display the full chain naturally. str(exc) renders as "[Type] Message" when type is known. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On asyncio.CancelledError during send_request, IpcConnection fires off a best-effort CancellationRequest frame (matching the original Request.id) before re-raising. The send is a background task so the caller's cancellation propagates immediately and the message goes out asynchronously on the same writer. Failures during the cancellation send are swallowed (writer may already be closing). The original CancelledError reaches the caller intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcClient(transport, request_timeout=5.0) configures a single knob
that both:
- sets Request.TimeoutInSeconds on every outgoing call (server-side
deadline), and
- wraps each proxy call in asyncio.wait_for(...) (client-side
deadline → asyncio.TimeoutError).
When the client-side timeout fires, asyncio.wait_for cancels
send_request, which triggers the existing C.2 cancellation forwarding
— the server receives a CancellationRequest matching the timed-out id.
Per-call override is left to the caller via `async with asyncio.timeout(t)`
or `asyncio.wait_for(...)`. No timeout parameter on signatures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcConnection's receive loop now sets is_closed=True in a finally, regardless of which path exited (clean EOF, OSError, unexpected exception). IpcClient._ensure_connected sees the dead connection, acloses it cleanly (idempotent), and re-dials via the transport. The proxy instance is stable across reconnects — same get_proxy result keeps working after the underlying stream is replaced. In-flight calls when the drop happens still propagate the underlying error (no silent retry). Auto-reconnect only fires on the *next* call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps asyncio.open_connection(host, port) behind the same ClientTransport interface. Same shape as NamedPipeClientTransport — frozen+slotted dataclass, connect() returns the standard (StreamReader, StreamWriter) pair. Includes a loopback smoke test that spins up an asyncio TCP server, connects, and exchanges bytes — covering the actual networking path in addition to the constructor/immutability unit tests. Re-exported at the top-level package alongside the named-pipe one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README.md replaces the stub; mirrors the .NET README structure (install, quick start, contracts, cancellation/timeouts/errors, auto-reconnect, transports, what's out of scope) but Python-idiomatic throughout. - src/uipath_ipc/py.typed (PEP 561 marker) — signals to mypy/pyright that this package ships inline type information. - pyproject.toml: explicit [tool.hatch.build.targets.wheel].packages so hatchling reliably picks up the src layout and includes py.typed. - Smoke tests now verify the documented public surface stays exported and that py.typed travels into the package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tests/integration/ holds tests that exercise the Python client against the real IpcSample.ConsoleServer. A session-scoped fixture launches `dotnet run --framework net6.0`, waits for "Server started" on stdout, yields, then signals CTRL_BREAK (Win) / SIGINT (POSIX) to shut it down. Gated behind `--integration`. Default `pytest` skips them so the unit loop stays fast (62 passed, 7 skipped, ~0.36s). Run with `pytest --integration` to exercise the live interop path. Coverage: AddFloats, MultiplyInts, EchoString, AddComplexNumbers, DivideByZero (verifying RemoteException with type_name), and multi-call reuse on a single client. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The --integration flag becomes --no-integration. Integration tests now run as part of the default `pytest` invocation; pass --no-integration to skip them. VS Test Explorer's Run All will now include the .NET interop suite (launching IpcSample.ConsoleServer via dotnet run). First cold run incurs the dotnet build cost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.NET's NamedPipeServerStream pattern creates pipe instances on demand — there's a small window between accepting one connection and creating the next during which CreateFile returns ERROR_FILE_NOT_FOUND. This shows up as `FileNotFoundError: [WinError 2]` for clients connecting in the wrong moment (test sessions, server restarts, deploys). NamedPipeClientTransport._connect_windows now retries on FileNotFoundError with a bounded backoff (total ~1.85s across 6 tries) before giving up. All other errors still propagate immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds IpcSample.PythonClientTestServer/ — a net8.0 console host purpose-
built for the Python integration suite:
- AddConsole() logging so handler activity is visible.
- Simple callback-free service implementations (the existing
IpcSample.ConsoleServer's MultiplyInts depends on a client-side
callback, unusable from a callback-less Python client).
- Stable "READY pipe=<name>" startup marker.
- Pipe name configurable via CLI arg (defaults to "uipath-ipc-py-test").
The Python integration fixture launches this project, runs a background
thread to drain the server's stdout, and dumps the full transcript at
session teardown for diagnostics.
Fixes a Python-side wire bug: .NET Request.TimeoutInSeconds is a
non-nullable `double`, with 0 as the "no timeout, use default" sentinel.
Emitting JSON null on this field made Newtonsoft.Json reject the entire
Request during constructor binding and the server silently dropped the
connection. Request.to_dict now emits 0.0 in place of None;
from_dict symmetrically decodes 0/0.0 back to None.
Adds tests/wire/test_dotnet_compatibility.py — 14 tests asserting the
serialized wire shape literally matches the .NET schema
(UiPath.CoreIpc/Wire/Dtos.cs). Catches this class of regression at
unit-test time without needing the integration suite to run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VS's Python Tools (and other IDE debuggers) need debugpy on the active interpreter to launch a debug session. Without it, "Debug Test" in VS Test Explorer fails with the vague "Path to debug adapter executable not specified" dialog. Putting it in [project.optional-dependencies].dev means a fresh `pip install -e ".[dev]"` after clone Just Works for debugging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a81c8a6 to
b48e3cc
Compare
Two CI-side changes needed before the Python work can land green: - Switch Npm@1's customFeed from the org-level `npm-packages` feed (managed outside CoreIpc) to a project-scoped `uipath-ipc-deps` (CoreIpc/9a5bdfb1-...). Same npmjs.org upstream, project ownership, PyPI upstream already enabled for the eventual Python publishing. - Disable the org-wide Safe Chain Guard pipeline decorator via SCG_KILL_SWITCH=true at pipeline scope. The Aikido shim that SCG injects ahead of every npm/python invocation started failing installs with Azure-Storage-SAS-shaped 403s (last green CoreIpc build was 2026-04-30, after the SCG rollout). Pipeline scope is required — task-env scope is too late, the decorator runs in pre-job. CoreIpc temporarily opts out of SCG-side malware scanning; revisit when the DevOps fix lands. See azp-nodejs.yaml's inline comment for the full Slack-referenced story so the next person on this trail doesn't repeat the dead-ends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
abe0ccf to
b3cdd0e
Compare
Reshapes the pipeline so:
- Publishing is opt-in via parameters (`publishNuGet`, `publishNpm`,
default false). Default runs build + test, never push. When a
publish parameter is true, its stage runs and gates on its
environment's approval check.
- NuGet now follows the same approval-gated pattern as NPM, via a
new `NuGet-Packages` environment that mirrors `NPM-Packages`'
approval check. The `dotnet nuget push` moves out of
azp-dotnet-dist.yaml (which built+pushed unconditionally) into a
new `azp-nuget.publish.steps.yaml` under the gated stage.
- Publish stages can replay against a previous successful build via
`reuseArtifactsFromBuildId`. When set, the Build stage is Skipped
(not Failed) and the Publish stages download from the specified
build. When unset, both behave as before.
- Job names move from environment-centric (".NET on Windows",
"node.js on Windows", "node.js on Ubuntu") to deliverable-centric
("NuGet — .NET on Windows", "NPM — Node + Web on Windows",
"NPM — Node + Web on Linux (test-only)"). The "test-only" marker
signals the Linux job is a cross-platform check, not a second
source of artifacts.
- Rejecting an approval no longer leaves the run as Failed — the
Publish stages start in `Skipped` state when their parameter is
false, so the rejection-as-failure footgun is gone.
Out of this pass: Python jobs in Build, Python publish stage, and any
move of `PublishSymbols` into the gated NuGet publish — left as
follow-ups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the NPM publish so the project-scoped Azure Artifacts feed (uipath-ipc-deps) is the primary, always-working target — the pipeline's build-service identity is already an administrator on it, no PAT rotation involved. The existing GitHub Packages publish stays wired up but is marked continueOnError: true. It's currently expected to fail: post Mini Shai-Hulud (2026-05-11/12 npm supply-chain incident), UiPath revoked classic PATs org-wide and migrated everyone to fine-grained PATs, which don't expose the Packages permission at org level. Per Liviu Bud's 2026-05-25 #dev announcement, a sanctioned pipeline-auth replacement is in progress but not yet available. When the platform team ships the replacement, updating the PublishNPM service connection and dropping continueOnError will restore the GitHub Packages publish without any other code change. Until then, runs of Publish_NPM finish as "Succeeded with issues" rather than Failed — packages still ship to uipath-ipc-deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous attempt used publishFeedCombined which the Npm@1 task ignored, making it fall back to a default registry URL (uipath.pkgs.visualstudio .com/_packaging/npm/registry/ — no project, no feed id) and 404 on PUT. The correct input name on the task is publishFeed. The value format "project/feedId" stays the same as we used for customFeed on the install side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| # Void / fire-and-forget operations answer with an empty Data string | ||
| # (not null) — e.g. .NET CoreIpc's response for a `Task`-returning | ||
| # method. Treat empty (or null) Data as "no return value". | ||
| if not resp.data: |
There was a problem hiding this comment.
A value-typed return answered with empty/null Data silently becomes None, where .NET throws.
This short-circuits any falsy resp.data to None before the declared return type is consulted. Reachable with no contract mismatch: a Python server method declared -> int whose impl returns None ships Data=null (one-way is keyed on the impl's own annotation — connection.py L170 — and data = None if result is None … L691). The client gets None; count + 1 then raises TypeError far from the call.
.NET deserializes ""/null into a non-nullable value type by throwing JsonSerializationException at the call (Dtos.cs L49 → Deserialize(Data ?? "", typeof(TResult))) — fail-loud. (The legit -> None void path → Data="" → null stays parity and is fine.)
Repro (3.10, loopback): GetCount() -> int with an impl that returns None → client gets None; count + 1 raises TypeError.
Fix: gate the early None on the return hint — return None only for None/Optional/object/Any/str, otherwise raise (or surface a clear decode error).
There was a problem hiding this comment.
great find, thanks
There was a problem hiding this comment.
Will not fix, UiPath.Ipc is a kit where we don't hold the hand of end-users due to the dynamic nature of their language/runtime.
This is about returned values, but the problem exists for arg-param transfers and also for class fields within those returned values/args.
Contract mismatches in general between Ipc counterparts are not supported (all bets are off).
Also, if Python's dynamic nature makes it so that regardless of the type hint, the method actually returns something incompatible along the way, then that will caused undefined errors (acceptable).
The same already goes for TypeScript where the type declarations are tentative, and through careful linting and avoiding bugs, things work, but the system doesn't validate anything. Any function that in the typescript definition vows to return a number can actually choose at runtime to returns something else, and the failure will be late, in the callee, after the JSON string was transported.
| elif tag == "varargs": | ||
| pos.extend(wire) | ||
| elif tag == "wire": | ||
| nxt = next(wire, _MISSING) |
There was a problem hiding this comment.
A missing trailing arg for a required param raises TypeError (handler never runs), where .NET fills default(T) and invokes it.
On wire exhaustion the wire branch simply doesn't append, relying on the param having a Python default. If a missing trailing param has none, method(*pos, **kwargs) raises TypeError: missing … argument → returned as an Error, handler never invoked. This bites the forward-compat case: an older client omitting an arg the server later added.
.NET fills every missing trailing slot with default(T) (null for reference types, 0/false for value types) and invokes the handler (SetOptionalArguments, Server.cs L237; GetDefaultValue, Helpers.cs L52).
Repro (3.10): Add(a, b) called with [42] → TypeError, handler not run; control with a Python default works.
Should this match .NET? Filling a required-but-missing wire param with the type's zero value (else None) instead of skipping would align the forward-compat behavior — or is a hard error here the intended contract?
There was a problem hiding this comment.
systemic missuses shouldn't be treated with too much care
There was a problem hiding this comment.
.NET doesn't fill every missing trailing slot, but is actually wacky in behavior:
- for CancellationToken it does fill it with the default
- for any other value type it fails with NRE
- for all reference types, it silently provides
null
This sillyness isn't something to lean towards, and I'd very much keep the TypeError, until we decide to revamp .NET too and then align all 3 runtimes.
to_wire no longer transforms non-string keys (UUID/datetime/enum) — JSON keys are strings and the .NET contracts never use non-string-keyed dicts. from_wire already passes keys through, so both sides now agree on string keys only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match .NET's ObjectDisposedException: a closed server no longer silently re-binds a fresh listener. aclose() marks it disposed; start() then raises. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
start()/aclose() now mutate _disposed/_handle under the same lock (held across serve()), so a concurrent aclose() can't slip a listener past the disposed check or leave one un-closed. Slow teardown runs outside the lock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documentation-only decorator marking contract methods whose .NET counterpart ends with a CancellationToken. Zero wire effect: the Python signature omits the token (cancellation is task-based, delivered out-of-band as a CancellationRequest), and the .NET server fills/injects the trailing slot by type. The token must be the last .NET parameter (a method-level marker can't carry position). Annotates both e2e contract sets to exercise it against the real .NET server, adds a cancellation-propagation e2e test, and unit tests proving it adds nothing to the wire. Documented in the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The marker now controls whether a local cancel/timeout forwards a CancellationRequest to the peer: only @ipc_cancellable methods do. An unmarked method cancels locally only — signalling a peer that has no CancellationToken to observe it is pointless. send_request gains a propagate_cancellation kwarg (default False); the proxy sets it from the contract method's marker. The request's Parameters are unchanged (no token slot added). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd handler cancel() no-ops on an already-done task, and there's no await between the lookup and the cancel for the state to race — the not-done() guard handled nothing and read like a (nonexistent) TOCTOU. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A peer CancellationRequest now cancels an in-flight inbound handler only when the hosted contract method is @ipc_cancellable — symmetric with the client's send gate. _incoming_handlers carries the per-request cancellable flag (resolved from the contract at dispatch time); _handle_incoming_cancellation honors it. Teardown still cancels every handler. The cancellable lookup is now a shared, hardened markers.is_ipc_cancellable (skips private/dunder names, tolerates non-weak-referenceable descriptors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A connect accepted while aclose() awaits the listener (its connection_made fires after the shutdown snapshot was taken) was re-added to _connections and served — leaking a live connection past close. _on_connection now rejects + closes a connection once _disposed is set (flipped atomically under the lifecycle lock, and the callback is synchronous, so the read can't race the snapshot). Adds a burst test, verified red->green (5 leaked -> 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2) aclose() awaited writer.wait_closed() after a graceful close(); on the Windows ProactorEventLoop that defers connection_lost until the write buffer drains, so a non-reading peer with a buffered frame stalled it forever — and since IpcServer.aclose() closes connections in series, one deaf peer wedged the entire server shutdown. Now it aborts the transport (guarded so test fakes without a .transport are a no-op), matching .NET's synchronous Connection.Dispose() and the TS client's socket.destroy(). Adds a hang test, verified red->green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_type_hints() is all-or-nothing: one unresolvable annotation (classically a
TYPE_CHECKING-only param/field type) made the code fall back to {} and de-type
the WHOLE method or dataclass. New shared resolve_hints() degrades only the
unresolvable name to Any and keeps the rest — so the other params still decode,
one-way (-> None) is still detected, and sibling dataclass fields still
materialize. Used by inbound dispatch (_dispatch_plan, #5) and dataclass
decoding (_from_wire_dataclass, #6).
Two red->green tests using TYPE_CHECKING-only annotations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cancel-send was a detached task that self-suppressed on _closed, so a cancellation unwinding through `async with` (a timeout / parent-cancel) lost the race to aclose() and was silently dropped — leaving the peer running the cancelled handler orphaned. Now the @ipc_cancellable cancel-send runs INLINE at cancel time (bounded, best-effort) before re-raising, so the frame is written before the unwind reaches aclose() — matching .NET, which initiates the send before dispose. The _closed self-suppress is gone. Red->green test: a cancel unwinding through aclose() still delivers the frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…viour) A README section capturing the deliberate boundaries reasoned through in the review round: both peers must agree on the contract (mismatch = undefined behaviour, no hand-holding), variadic *args are delivered undecoded, arg-count must match (loud TypeError, no silent defaults), request ids are library-managed, and a CancellationToken must be the last .NET param. Plus an in-code note at the varargs binding site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents the by-design boundaries and cross-client behaviour differences across the .NET, Python, and TypeScript clients: contract-mismatch = undefined behaviour, the per-argument JSON envelope, value-type fidelity (decimal/Int64 precision loss on Python/TS inbound, datetime/enum/dict-key caveats), cancellation propagation differences (incl. the TS local-only gap), the transport/feature/message-size matrix, and argument/method constraints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts the cross-client interop & limitations content into a standalone repo-root LIMITATIONS.md (referenced by README.md instead of inlined), and makes the TypeScript story explicit: TS has no decimal or 64-bit integer type (all numbers are IEEE-754 doubles), so high-precision decimals and large Int64 lose precision there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.NET (and TypeScript) accept a CancellationToken at any parameter position — it's matched by type at runtime (client blanks its slot, server injects the live token by type), verified with a (CancellationToken, int, int) contract round-tripping .NET<->.NET. The Validator.CheckCancellationToken that would forbid a non-last token is dead code (Validator.Validate is never invoked; only Transport.Validate is wired in). Only Python needs the token last (it omits the token from the signature, so a non-last one misaligns the remaining args). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cancellation signal is out-of-band (a CancellationRequest frame), but the
CancellationToken parameter still occupies an inert positional slot on the wire
— .NET serializes it as an empty string, TS as {}, Python omits it — and the
receiver ignores the slot content (server injects the live token by type). So
"never a wire parameter" was overstated; the slot exists but has no effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a Python client and server for UiPath.Ipc that speaks the same wire protocol as the .NET package, plus the CI/CD plumbing to build, test, and publish it. Published and resolvable on the project-scoped
uipath-ipc-depsfeed. The Python package version tracksUiPath.CoreIpc(<Version>2.5.1</Version>), stamped to a PEP 440 local-version string per CI run (e.g.2.5.1+<build>) bysrc/CI/stamp-python-version.py— sopyproject.toml's version is a placeholder the pipeline overwrites.Scope — landed incrementally, each in its own commit:
IpcServerhosts services that a .NET or Python client calls.message.client.get_callback(...), the Python analog of .NET'sm.Client.GetCallback<T>().Streams (upload / download) remain explicitly out of scope.
Python client (
src/Clients/python/uipath-ipc/)/tmp/CoreFxPipe_*) and TCP — both client and server halves — with bounded retry onFileNotFoundErrorto ride out the peer's accept-loop warm-up.IpcClient(transport=..., callbacks={IClientCallback: impl}): lazy connect, dynamic__getattr__proxy viaget_proxy(IContract), auto-reconnect on transport drop, optionalrequest_timeout.RemoteExceptionpreservesmessage/type_name/stack_trace/innerchain, sets__cause__for traceback display.CancellationTokenparameter on signatures. The proxy forwardsCancellationRequeston the wire when its task is cancelled; peer-initiated cancellation aborts the matching in-flight handler.py3-none-anywith PEP 561py.typed.Callback (server → client) support
IpcClient(callbacks={Contract: instance})registers handler objects keyed by interface name.IpcConnectiondispatches incoming REQUEST frames: looks up the endpoint by interface name, resolves the method by name,json.loadseach parameter individually (matching the .NET convention), invokes (awaitable or sync), and writes a Response back. A write lock keeps concurrent outbound frames aligned.RemoteException. Peer-initiatedCancellationRequestcancels the matching handler task; the emitted error mimics .NET'sOperationCanceledException.CancellationTokenparameters — the caller doesn't include CT in the wireParametersarray (matches the existing .NETIComputingCallbackconvention).Server —
IpcServer(NEW)IpcServer(transport, services={IContract: impl}, request_timeout=None): listens via aServerTransportand wraps each accepted client in the existing symmetricIpcConnection, whosecallbacksdict is the hosted-service set. Duck-typed dispatch by contract__name__(the instance need not inherit the contract).TcpServerTransport(asyncio.start_server) andNamedPipeServerTransport(start_serving_pipeon Windows,start_unix_serverat/tmp/CoreFxPipe_<name>on POSIX).start/serve_forever/aclose+ async context manager;connection_countintrospection. Live connections are pruned via a newIpcConnection.add_close_callbackhook that fires once on explicit close or peer disconnect.aclosecloses connections before awaiting the listener, so Python 3.12+'sServer.wait_closed()(which blocks until active connections finish) doesn't hang.Handler reach-back —
Message.Client/GetCallback(NEW)message.py:Message/Message[T]+ anIClientprotocol. A service or callback method opts into a handle on its caller by declaring aMessageparameter; the wire never carries it (mirrors .NET's trailing-Messageconvention — the caller sends the same args either way).IpcConnection.get_callback(Contract)returns a proxy bound to that connection, so a handler can call the peer back mid-request — the inverse direction of the in-flight call. Python analog ofm.Client.GetCallback<T>(). Reached asm.client.get_callback(IContract)._invoke_callback) does signature-aware binding: wire args fill the real parameters, aMessage(.client= the connection,.request_timeoutcarried through) is injected at its slot; the no-Messagefast path is unchanged and cached per function. Works symmetrically for client-hosted callbacks and server-hosted services, since both run through the oneIpcConnection.Dedicated .NET test server (
src/IpcSample.PythonClientTestServer/)Purpose-built for the Python integration suite — net8.0, console logging on, stable
READY pipe=<name>startup marker. HostsIComputingService/ISystemService(callback-free) plusICallbackTester, whose handlers callm.Client.GetCallback<IClientCallback>()to exercise the server→client callback path against the real .NETMessage.Clientreach-back.Wire-shape tests
tests/wire/test_dotnet_compatibility.pyasserts our serialized JSON against the .NET schema literally (field sets, types,TimeoutInSecondsis a non-null double, etc.) — catches a class of regression at unit-test time that previously only surfaced when the live integration suite ran. This came out of finding (and fixing) an actual mismatch:TimeoutInSeconds: nullmade Newtonsoft drop the entire Request silently.CI / pipeline redesign
publishNuGet,publishNpm,publishPyPI(all defaultfalse). Default CI runs build + test only. Old behavior of always-pushing NuGet on every branch and always-popping an approval prompt is gone — and a rejected approval no longer leaves the run as Failed.buildNuGet,buildNpm,buildPython(all defaulttrue).reuseArtifactsFromBuildIdparameter lets a Publish stage replay against a prior successful build, skipping Build entirely.(test-only)marker disambiguates the Linux matrix runs from the artifact-producing Windows ones.NuGet-PackagesandPyPI-Packagesmirror the existingNPM-Packagesapproval shape (same approver, same 12h timeout).Feed swap + Supply Chain Guard bypass
npm installcustomFeedmoved from the org-levelnpm-packages(managed outside CoreIpc) to a project-scopeduipath-ipc-deps(CoreIpc-owned, mirrors npmjs.org + PyPI + NuGet).SCG_KILL_SWITCH=trueto bypass the org-wide Aikido Safe Chain shim, which was interfering with npm tarball downloads. Comments inazp-start.yamlandazp-nodejs.yamldocument the trail (with #devops Slack links) so the next person on this doesn't redo the dead-ends.NPM publish
uipath-ipc-depsfirst (uses the pipeline's built-in identity — no PAT). GitHub Packages stays wired up ascontinueOnError: trueuntil the platform team ships the post-Mini-Shai-Hulud pipeline-auth replacement (tracked in Liviu Bud's #dev thread).Publish_NPMfinish as "Succeeded with issues" until the GitHub side resolves — packages still ship touipath-ipc-deps.Review round — robustness fixes +
@ipc_cancellableA review pass (14 threads): six correctness fixes, each with a red→green regression test; seven items deliberately declined (undefined-behaviour / systemic-misuse, reasoned in-thread); plus a new
@ipc_cancellablecontract marker. Suite is now 234 unit + 22 .NET-interop tests, green.Fixes
to_wireno longer transforms non-string keys; JSON keys are strings and the .NET contracts never use non-string-keyed dictionaries (380ac9d).IpcServersingle-use, lifecycle under one lock —start()afteraclose()raises (matches .NET'sObjectDisposedException);start()/aclose()mutate_disposed/_handleunder one lock so they can't race (3930249,31f4ee8).aclose()is tearing down is rejected+closed instead of being served past close (957b32e).aclose()can't hang on a non-reading peer — aborts the transport instead of awaiting a graceful flush, matching .NET's synchronousConnection.Dispose()and the TS client'ssocket.destroy()(18b3876).TYPE_CHECKING-only type) degrades only that name toAny; stdlibget_type_hintsis all-or-nothing and previously de-typed the whole method/dataclass (d44ed8a).CancellationRequestis sent inline at cancel time, so a cancellation unwinding throughasync withreaches the peer before teardown, matching .NET initiating the send before dispose (95b2150).@ipc_cancellable(4d16e7e,1783f63,801a34f,af91a0b) — a contract-method marker for methods whose .NET counterpart ends in aCancellationToken(which Python signatures omit, since cancellation is task-based). It gates cancellation propagation: only marked methods emit aCancellationRequestwhen cancelled and honor an inbound one; an unmarked call cancels locally only. No effect on the request's arguments. Documented in the README and applied to the integration-suite contracts.Declined by design — bad inbound arg → opaque crash; value-typed empty/null
Data→None; error-reply build on a cyclic exception cause; unguarded result decode on contract/wire mismatch; reused request id; variadic*argselement decoding; and missing-required-arg (kept as a loudTypeErrorrather than mirroring .NET's null-fill/NRE). All undefined-behaviour or systemic-misuse — not worth defensive code.Test plan
pytest) and in CI.RemoteExceptionpropagation and task-based cancellation.ICallbackTester(realm.Client.GetCallback<IClientCallback>()) —test_dotnet_interop.py#L136-L183.IpcServer): TCP + named-pipe loopback (Python client ↔ Python server), lifecycle, connection-count pruning —test_ipc_server.py.get_callback→ client callback → back —test_ipc_server.py#L196-L242; plusMessage-injection /get_callbackunits —test_message_injection.py.test_connection.py#L133-L173.Publish_PyPIpublishes the wheel touipath-ipc-deps, version stamped fromUiPath.CoreIpc+ build number (PEP 440 local segment, e.g.2.5.1+<build>).uipath-robot-client(separate repo, own venv,index-url=uipath-ipc-deps) pinsuipath-ipc>=2.5.1+20260611.6and its 100-test suite passes against the published wheel.IpcServerinterop (reverse ofIpcSample.PythonClientTestServer): a real .NET client drives the Python server — direct calls, reach-back into a .NET-hosted callback, and aRemoteExceptionround-trip —test_dotnet_client_interop.py+IpcSample.PythonServerTestClient. Surfaced two dispatch fixes: bind wire args to the handler signature (tolerate a .NET client's trailingCancellationToken), and close the writer on peer disconnect (transport-leak fix).serve_forever()returned immediately for Windows named pipes, tearing the server down), madeMessageinjection keyword-based so keyword-only /Optional[Message]params inject correctly, unified connection teardown (now cancels in-flight handlers on peer disconnect), and dropped the_ConnectionInvokershim. 4 regression tests added..NETflakeSystemTestsOverTcp.NotPassingAnOptionalMessage_ShouldWork(tight 800ms timeout, pre-dates this PR). Tracked separately.continueOnErroronce platform team ships sanctioned pipeline auth.src/Clients/python/_attempt0/(the reference port; shipping impl issrc/Clients/python/uipath-ipc/).🤖 Generated with Claude Code