Skip to content

[ROBO-5722] Add Python client and server for UiPath.Ipc#125

Open
eduard-dumitru wants to merge 90 commits into
masterfrom
feature/python
Open

[ROBO-5722] Add Python client and server for UiPath.Ipc#125
eduard-dumitru wants to merge 90 commits into
masterfrom
feature/python

Conversation

@eduard-dumitru

@eduard-dumitru eduard-dumitru commented May 28, 2026

Copy link
Copy Markdown
Collaborator

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-deps feed. The Python package version tracks UiPath.CoreIpc (<Version>2.5.1</Version>), stamped to a PEP 440 local-version string per CI run (e.g. 2.5.1+<build>) by src/CI/stamp-python-version.py — so pyproject.toml's version is a placeholder the pipeline overwrites.

Scope — landed incrementally, each in its own commit:

  1. Client — dynamic-proxy RPC over Named Pipe / TCP.
  2. Bidirectional callbacks — the client hosts callback services the server invokes.
  3. ServerIpcServer hosts services that a .NET or Python client calls.
  4. Handler reach-backmessage.client.get_callback(...), the Python analog of .NET's m.Client.GetCallback<T>().

Streams (upload / download) remain explicitly out of scope.

Python client (src/Clients/python/uipath-ipc/)

  • Pure-Python, asyncio-based; talks the same wire to any UiPath.Ipc peer.
  • Transports: NamedPipe (Win + POSIX via /tmp/CoreFxPipe_*) and TCP — both client and server halves — with bounded retry on FileNotFoundError to ride out the peer's accept-loop warm-up.
  • IpcClient(transport=..., callbacks={IClientCallback: impl}): lazy connect, dynamic __getattr__ proxy via get_proxy(IContract), auto-reconnect on transport drop, optional request_timeout.
  • RemoteException preserves message / type_name / stack_trace / inner chain, sets __cause__ for traceback display.
  • Cancellation is task-based; no CancellationToken parameter on signatures. The proxy forwards CancellationRequest on the wire when its task is cancelled; peer-initiated cancellation aborts the matching in-flight handler.
  • Wheel ships as py3-none-any with PEP 561 py.typed.

Callback (server → client) support

  • IpcClient(callbacks={Contract: instance}) registers handler objects keyed by interface name.
  • IpcConnection dispatches incoming REQUEST frames: looks up the endpoint by interface name, resolves the method by name, json.loads each parameter individually (matching the .NET convention), invokes (awaitable or sync), and writes a Response back. A write lock keeps concurrent outbound frames aligned.
  • Handler exceptions wire back as RemoteException. Peer-initiated CancellationRequest cancels the matching handler task; the emitted error mimics .NET's OperationCanceledException.
  • Callback methods must NOT declare CancellationToken parameters — the caller doesn't include CT in the wire Parameters array (matches the existing .NET IComputingCallback convention).

Server — IpcServer (NEW)

  • IpcServer(transport, services={IContract: impl}, request_timeout=None): listens via a ServerTransport and wraps each accepted client in the existing symmetric IpcConnection, whose callbacks dict is the hosted-service set. Duck-typed dispatch by contract __name__ (the instance need not inherit the contract).
  • Server transports: TcpServerTransport (asyncio.start_server) and NamedPipeServerTransport (start_serving_pipe on Windows, start_unix_server at /tmp/CoreFxPipe_<name> on POSIX).
  • start / serve_forever / aclose + async context manager; connection_count introspection. Live connections are pruned via a new IpcConnection.add_close_callback hook that fires once on explicit close or peer disconnect.
  • aclose closes connections before awaiting the listener, so Python 3.12+'s Server.wait_closed() (which blocks until active connections finish) doesn't hang.

Handler reach-back — Message.Client / GetCallback (NEW)

  • message.py: Message / Message[T] + an IClient protocol. A service or callback method opts into a handle on its caller by declaring a Message parameter; the wire never carries it (mirrors .NET's trailing-Message convention — 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 of m.Client.GetCallback<T>(). Reached as m.client.get_callback(IContract).
  • Dispatch (_invoke_callback) does signature-aware binding: wire args fill the real parameters, a Message (.client = the connection, .request_timeout carried through) is injected at its slot; the no-Message fast path is unchanged and cached per function. Works symmetrically for client-hosted callbacks and server-hosted services, since both run through the one IpcConnection.

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. Hosts IComputingService / ISystemService (callback-free) plus ICallbackTester, whose handlers call m.Client.GetCallback<IClientCallback>() to exercise the server→client callback path against the real .NET Message.Client reach-back.

Wire-shape tests

tests/wire/test_dotnet_compatibility.py asserts our serialized JSON against the .NET schema literally (field sets, types, TimeoutInSeconds is 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: null made Newtonsoft drop the entire Request silently.

CI / pipeline redesign

  • Parameterized publish: publishNuGet, publishNpm, publishPyPI (all default false). 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.
  • Parameterized build (opt-out): buildNuGet, buildNpm, buildPython (all default true).
  • Per-technology Build stages so NuGet / NPM / Python race to the finish line independently.
  • reuseArtifactsFromBuildId parameter lets a Publish stage replay against a prior successful build, skipping Build entirely.
  • Job names shifted from environment-centric (".NET on Windows", "node.js on Ubuntu") to deliverable-centric ("NuGet — .NET on Windows", "NPM — Node + Web on Linux (test-only)", "Python — Windows"). The (test-only) marker disambiguates the Linux matrix runs from the artifact-producing Windows ones.
  • New environments NuGet-Packages and PyPI-Packages mirror the existing NPM-Packages approval shape (same approver, same 12h timeout).

Feed swap + Supply Chain Guard bypass

  • npm install customFeed moved from the org-level npm-packages (managed outside CoreIpc) to a project-scoped uipath-ipc-deps (CoreIpc-owned, mirrors npmjs.org + PyPI + NuGet).
  • Pipeline-level SCG_KILL_SWITCH=true to bypass the org-wide Aikido Safe Chain shim, which was interfering with npm tarball downloads. Comments in azp-start.yaml and azp-nodejs.yaml document the trail (with #devops Slack links) so the next person on this doesn't redo the dead-ends.

NPM publish

  • Now writes to uipath-ipc-deps first (uses the pipeline's built-in identity — no PAT). GitHub Packages stays wired up as continueOnError: true until the platform team ships the post-Mini-Shai-Hulud pipeline-auth replacement (tracked in Liviu Bud's #dev thread).
  • Runs of Publish_NPM finish as "Succeeded with issues" until the GitHub side resolves — packages still ship to uipath-ipc-deps.

Review round — robustness fixes + @ipc_cancellable

A 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_cancellable contract marker. Suite is now 234 unit + 22 .NET-interop tests, green.

Fixes

  • dict serialization is string-keyed onlyto_wire no longer transforms non-string keys; JSON keys are strings and the .NET contracts never use non-string-keyed dictionaries (380ac9d).
  • IpcServer single-use, lifecycle under one lockstart() after aclose() raises (matches .NET's ObjectDisposedException); start()/aclose() mutate _disposed/_handle under one lock so they can't race (3930249, 31f4ee8).
  • No connection leak on shutdown — a client accepted while 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 synchronous Connection.Dispose() and the TS client's socket.destroy() (18b3876).
  • Resilient type-hint resolution — one unresolvable annotation (e.g. a TYPE_CHECKING-only type) degrades only that name to Any; stdlib get_type_hints is all-or-nothing and previously de-typed the whole method/dataclass (d44ed8a).
  • Cancellation delivered on cancel-then-close — the CancellationRequest is sent inline at cancel time, so a cancellation unwinding through async with reaches 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 a CancellationToken (which Python signatures omit, since cancellation is task-based). It gates cancellation propagation: only marked methods emit a CancellationRequest when 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 DataNone; error-reply build on a cyclic exception cause; unguarded result decode on contract/wire mismatch; reused request id; variadic *args element decoding; and missing-required-arg (kept as a loud TypeError rather than mirroring .NET's null-fill/NRE). All undefined-behaviour or systemic-misuse — not worth defensive code.

Test plan

  • CI green on Windows + Linux for Build (all jobs).
  • 111 non-.NET + 11 .NET-interop Python tests pass (122 total) locally (pytest) and in CI.
  • Client RPC over Named Pipe + TCP, incl. RemoteException propagation and task-based cancellation.
  • Server→client callbacks: 3 round-trips against the .NET ICallbackTester (real m.Client.GetCallback<IClientCallback>()) — test_dotnet_interop.py#L136-L183.
  • Server (IpcServer): TCP + named-pipe loopback (Python client ↔ Python server), lifecycle, connection-count pruning — test_ipc_server.py.
  • Handler reach-back: full-duplex re-entrancy E2E — client → server handler → get_callback → client callback → back — test_ipc_server.py#L196-L242; plus Message-injection / get_callback units — test_message_injection.py.
  • Close-callback hook (server connection pruning) — test_connection.py#L133-L173.
  • Publish_PyPI publishes the wheel to uipath-ipc-deps, version stamped from UiPath.CoreIpc + build number (PEP 440 local segment, e.g. 2.5.1+<build>).
  • Consumer-side install round-trips: uipath-robot-client (separate repo, own venv, index-url = uipath-ipc-deps) pins uipath-ipc>=2.5.1+20260611.6 and its 100-test suite passes against the published wheel.
  • .NET-client ↔ Python-IpcServer interop (reverse of IpcSample.PythonClientTestServer): a real .NET client drives the Python server — direct calls, reach-back into a .NET-hosted callback, and a RemoteException round-trip — test_dotnet_client_interop.py + IpcSample.PythonServerTestClient. Surfaced two dispatch fixes: bind wire args to the handler signature (tolerate a .NET client's trailing CancellationToken), and close the writer on peer disconnect (transport-leak fix).
  • High-effort code-review pass (7 finder angles) — fixed a real bug (serve_forever() returned immediately for Windows named pipes, tearing the server down), made Message injection keyword-based so keyword-only / Optional[Message] params inject correctly, unified connection teardown (now cancels in-flight handlers on peer disconnect), and dropped the _ConnectionInvoker shim. 4 regression tests added.
  • (deferred) Pre-existing .NET flake SystemTestsOverTcp.NotPassingAnOptionalMessage_ShouldWork (tight 800ms timeout, pre-dates this PR). Tracked separately.
  • (deferred) Restore GitHub Packages NPM publish + remove continueOnError once platform team ships sanctioned pipeline auth.
  • (deferred) Re-enable Safe Chain Guard once DevOps fixes the underlying shim.
  • Deleted src/Clients/python/_attempt0/ (the reference port; shipping impl is src/Clients/python/uipath-ipc/).

🤖 Generated with Claude Code

eduard-dumitru and others added 24 commits March 31, 2026 20:35
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>
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>
eduard-dumitru and others added 3 commits May 29, 2026 00:04
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>
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py Outdated
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/server/ipc_server.py
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py Outdated
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/wire/serialization.py Outdated
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/wire/serialization.py
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py
# 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 L49Deserialize(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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great find, thanks

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemic missuses shouldn't be treated with too much care

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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.

Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/proxy.py
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py
Comment thread src/Clients/python/uipath-ipc/src/uipath_ipc/client/connection.py
eduard-dumitru and others added 12 commits June 22, 2026 09:45
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>
@danutboanta danutboanta self-requested a review June 23, 2026 07:34
eduard-dumitru and others added 5 commits June 23, 2026 12:34
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>
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.

2 participants