Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Contracts are a **shared agreement** between both peers, and every client *trust
- **Propagation to the peer differs:**
- **.NET** — a fired token sends a `CancellationRequest`.
- **Python** — sends one **only for methods marked `@ipc_cancellable`** (otherwise the cancel is local-only); a hosted handler honors an inbound cancel only when its contract method is `@ipc_cancellable`.
- **TypeScript** — **does not send a cancel frame at all** (local-only; the remote keeps running) and throws on an inbound one. Known parity gap.
- **TypeScript** — as a **caller**, a fired token now sends a `CancellationRequest` to the peer (like .NET), so the remote actually stops; a token already cancelled *before* the call suppresses the request entirely (the remote never runs it), also mirroring .NET. As a **callee** (a hosted callback), it **honors an inbound cancel**: the running handler's per-call token fires, and — when the callback contract declares a trailing `CancellationToken`/`AbortSignal` — the live token (or a bridged `AbortSignal`) is injected so the handler can observe it. A callback registered by bare endpoint name carries no parameter-type metadata, so its arguments are left untouched (the cancel still fires the token, but nothing is injected into the handler).
- A successful response that arrives before the cancel is delivered — the result wins.

## Transports & features
Expand Down
17 changes: 17 additions & 0 deletions src/Clients/js/dotnet/UiPath.CoreIpc.NodeInterop/Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ public interface IArithmetic
Task<bool> SendMessage(Message<int> message);
}

// A callback the server invokes on the client; the client handler is expected
// to observe the cancellation the server triggers (the TypeScript side may
// receive it as a CancellationToken or, interchangeably, an AbortSignal).
public interface ICancellationCallback
{
Task<bool> Wait(CancellationToken ct = default);
}

public interface IAlgebra
{
Task<string> Ping();
Expand All @@ -21,6 +29,15 @@ public interface IAlgebra
Task<bool> Timeout();
Task<int> Echo(int x);
Task<bool> TestMessage(Message<int> message);
// Blocks until its (injected) CancellationToken fires, then throws — used
// to prove a client actually transmits a cancellation to the server.
Task<bool> WaitForCancellation(CancellationToken ct = default);
// How many times WaitForCancellation has observed a cancellation so far.
Task<int> CancellationCount();
// Invokes ICancellationCallback.Wait on the client, then cancels it — used
// to prove a client-hosted callback observes a server-initiated cancel.
// Returns what the callback returned (true once it observed the cancel).
Task<bool> CancelCallback(Message message = default!);
}

public interface ICalculus
Expand Down
37 changes: 37 additions & 0 deletions src/Clients/js/dotnet/UiPath.CoreIpc.NodeInterop/ServiceImpls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,43 @@ public async Task<bool> Sleep(int milliseconds, Message message = default!, Canc
public Task<bool> Timeout() => Task.FromException<bool>(new TimeoutException());

public Task<int> Echo(int x) => Task.FromResult(x);

private int _cancellationObservationCount;

public async Task<bool> WaitForCancellation(CancellationToken ct = default)
{
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
return false; // unreachable: only completes by cancellation
}
catch (OperationCanceledException)
{
Interlocked.Increment(ref _cancellationObservationCount);
throw;
}
}

public Task<int> CancellationCount() =>
Task.FromResult(Volatile.Read(ref _cancellationObservationCount));

public async Task<bool> CancelCallback(Message message = default!)
{
var callback = message.Client.GetCallback<ICancellationCallback>();
using var cts = new CancellationTokenSource();
var callbackTask = callback.Wait(cts.Token);
cts.CancelAfter(TimeSpan.FromMilliseconds(200));
try
{
// The client handler resolves true once it observes the cancel.
return await callbackTask;
}
catch (OperationCanceledException)
{
// Or the handler propagated the cancellation — still observed.
return true;
}
}
}

public class Calculus : ICalculus
Expand Down
86 changes: 86 additions & 0 deletions src/Clients/js/src/std/bcl/cancellation/AbortSignalAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { CancellationToken } from './CancellationToken';
import { CancellationTokenSource } from './CancellationTokenSource';

/**
* Bridges a Web/Node `AbortSignal` to a UiPath.Ipc `CancellationToken`, so a
* contract method may accept an `AbortSignal` anywhere a `CancellationToken` is
* accepted. Purely additive: `CancellationToken` / `CancellationTokenSource`
* are unchanged and remain fully supported.
*/
export class AbortSignalAdapter {
/** Whether `arg` is an `AbortSignal` (guarded for runtimes without it). */
public static isAbortSignal(arg: unknown): arg is AbortSignal {
return typeof AbortSignal !== 'undefined' && arg instanceof AbortSignal;
}

/**
* Check-and-adapt in one gulp: returns `candidate` unchanged if it is
* already a `CancellationToken`, the bridged token if it is an
* `AbortSignal`, or `undefined` if it is neither (i.e. not a cancellation
* argument). Lets callers treat both cancellation shapes uniformly.
*/
public static ensureCancellationToken(
candidate: unknown,
): CancellationToken | undefined {
if (candidate instanceof CancellationToken) {
return candidate;
}
if (AbortSignalAdapter.isAbortSignal(candidate)) {
return AbortSignalAdapter.toCancellationToken(candidate);
}
return undefined;
}

/**
* Returns a `CancellationToken` that is cancelled when `signal` aborts —
* immediately if it is already aborted.
*
* The backing `CancellationTokenSource` is created with no `cancelAfter`
* delay, so its only disposable resource (the delay timer) is never
* allocated; we still `dispose()` it right after it fires — for hygiene and
* to stay correct if the source ever gains resources. A never-aborting
* signal's source holds nothing and is collected together with the signal
* (the `abort` listener is registered `once`, so it self-removes).
*/
public static toCancellationToken(signal: AbortSignal): CancellationToken {
const cts = new CancellationTokenSource();
if (signal.aborted) {
cts.cancel();
cts.dispose();
} else {
signal.addEventListener(
'abort',
() => {
try {
cts.cancel();
} finally {
cts.dispose();
}
},
{ once: true },
);
}
return cts.token;
}

/**
* The reverse of {@link toCancellationToken}: returns an `AbortSignal` that
* aborts when `token` is cancelled — immediately if it already is. Used on
* the callee side to hand a callback handler an `AbortSignal` in place of a
* `CancellationToken`. Throws if the runtime has no `AbortController`.
*/
public static toAbortSignal(token: CancellationToken): AbortSignal {
if (typeof AbortController === 'undefined') {
throw new Error(
'Cannot adapt a CancellationToken to an AbortSignal: this runtime has no AbortController.',
);
}
const controller = new AbortController();
if (token.isCancellationRequested) {
controller.abort();
} else {
token.register(() => controller.abort());
}
return controller.signal;
}
}
1 change: 1 addition & 0 deletions src/Clients/js/src/std/bcl/cancellation/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './CancellationTokenSource';
export * from './CancellationTokenRegistration';
export * from './CancellationToken';
export * from './AbortSignalAdapter';
export * from './EmptyCancellationToken';
export * from './RandomCancellationToken';
export * from './LinkedCancellationTokenSource';
9 changes: 9 additions & 0 deletions src/Clients/js/src/std/core/Contract/ContractStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export class ContractStore implements IContractStore {
return this._map.get($class);
}

maybeGetByEndpoint(endpoint: string): ServiceDescriptor<unknown> | undefined {
for (const descriptor of this._map.values()) {
if (descriptor.endpoint === endpoint) {
return descriptor;
}
}
return undefined;
}

private add<TService>($class: PublicCtor<TService>): ServiceDescriptor<TService> {
const descriptor = new ServiceDescriptorImpl($class);
this._map.set($class, descriptor);
Expand Down
7 changes: 7 additions & 0 deletions src/Clients/js/src/std/core/Contract/IContractStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ export interface IContractStore {
getOrCreate<TService>($class: PublicCtor<TService>): ServiceDescriptor<TService>;

maybeGet<TService = unknown>($class: PublicCtor<TService>): ServiceDescriptor<TService> | undefined;

/**
* Looks up a registered contract by its endpoint name (defaults to the
* contract class name). Used on the callee side, where an incoming call
* identifies its contract by endpoint name rather than by class.
*/
maybeGetByEndpoint(endpoint: string): ServiceDescriptor<unknown> | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type OperationDescriptor = {

readonly methodName: string;
readonly hasEndingCancellationToken: boolean;
readonly hasEndingAbortSignal: boolean;
readonly returnType: PublicCtor;
readonly parameterTypes: readonly PublicCtor[];
returnsPromiseOf?: PublicCtor | Primitive;
Expand Down
10 changes: 10 additions & 0 deletions src/Clients/js/src/std/core/Contract/OperationDescriptorImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export class OperationDescriptorImpl<TService> implements OperationDescriptor {
parameterTypes[parameterTypes.length - 1] === (CancellationToken as any)
);
}
public get hasEndingAbortSignal(): boolean {
const parameterTypes = this.parameterTypes;

return (
typeof AbortSignal !== 'undefined' &&
parameterTypes &&
parameterTypes.length > 0 &&
parameterTypes[parameterTypes.length - 1] === (AbortSignal as any)
);
}
public get returnType(): PublicCtor {
return Reflect.getMetadata(
'design:returntype',
Expand Down
52 changes: 52 additions & 0 deletions src/Clients/js/src/std/core/Protocol/Rpc/IncomingCallTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CancellationToken, CancellationTokenSource } from '../../../bcl';

/**
* Tracks in-flight *incoming* calls (callbacks the peer invokes on us) by their
* request id, each backed by a {@link CancellationTokenSource}. It is the callee
* mirror of the outgoing-call table: it lets an inbound `CancellationRequest`
* frame cancel the matching running handler.
*
* @internal
*/
/* @internal */
export class IncomingCallTable {
private readonly _map = new Map<string, CancellationTokenSource>();

/**
* Starts tracking `requestId` and returns the token that fires when a
* cancellation for it arrives (or the call is torn down).
*/
public register(requestId: string): CancellationToken {
const cts = new CancellationTokenSource();
this._map.set(requestId, cts);
return cts.token;
}

/** Cancels the tracked call `requestId`, if still in flight. */
public tryCancel(requestId: string): void {
const cts = this._map.get(requestId);
if (cts && !cts.isCancellationRequested) {
cts.cancel();
}
}

/** Stops tracking `requestId` (the call finished) and releases its source. */
public complete(requestId: string): void {
const cts = this._map.get(requestId);
if (cts) {
this._map.delete(requestId);
cts.dispose();
}
}

/** Cancels and releases every tracked call — used when the channel dies. */
public clear(): void {
for (const cts of this._map.values()) {
if (!cts.isCancellationRequested) {
cts.cancel();
}
cts.dispose();
}
this._map.clear();
}
}
11 changes: 11 additions & 0 deletions src/Clients/js/src/std/core/Protocol/Rpc/RpcCallContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export module RpcCallContext {
constructor(
public readonly request: RpcMessage.Request,
public readonly respond: (response: RpcMessage.Response) => Promise<void>,
/**
* Cancelled when the peer sends a `CancellationRequest` for this
* call (or the channel tears it down). Defaults to a token that
* never cancels, so pre-existing construction sites are unaffected.
*/
public readonly ct: CancellationToken = CancellationToken.none,
) {}
}

Expand All @@ -29,5 +35,10 @@ export module RpcCallContext {
public complete(response: RpcMessage.Response): void {
const _ = this._pcs.trySetResult(response);
}

/** Faults the pending call — used when the channel is torn down. */
public fail(error: Error): void {
const _ = this._pcs.trySetFaulted(error);
}
}
}
Loading