Skip to content
Draft
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
15 changes: 15 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,13 @@ public interface IArithmetic
Task<bool> SendMessage(Message<int> message);
}

// A callback the server invokes then cancels; the client handler must observe 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 +28,14 @@ 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. Returns true
// once the client-hosted handler observed the server-initiated 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
73 changes: 73 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,73 @@
import { CancellationToken } from './CancellationToken';
import { CancellationTokenSource } from './CancellationTokenSource';

/**
* Bridges a Web/Node `AbortSignal` to a `CancellationToken`, so a contract method may
* accept an `AbortSignal` anywhere a `CancellationToken` is accepted.
*/
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;
}

/**
* `candidate` unchanged if it is a `CancellationToken`, the bridged token if it is
* an `AbortSignal`, or `undefined` if it is neither cancellation shape.
*/
public static ensureCancellationToken(
candidate: unknown,
): CancellationToken | undefined {
if (candidate instanceof CancellationToken) {
return candidate;
}
if (AbortSignalAdapter.isAbortSignal(candidate)) {
return AbortSignalAdapter.toCancellationToken(candidate);
}
return undefined;
}

/**
* A `CancellationToken` cancelled when `signal` aborts — immediately if already
* aborted. The `once` listener self-removes, so a never-aborting signal leaks nothing.
*/
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}, for handing a callback handler an
* `AbortSignal`. 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
3 changes: 3 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,7 @@ export interface IContractStore {
getOrCreate<TService>($class: PublicCtor<TService>): ServiceDescriptor<TService>;

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

/** For the callee side, where an incoming call identifies its contract by name. */
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
45 changes: 45 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,45 @@
import { CancellationToken, CancellationTokenSource } from '../../../bcl';

/**
* The callee mirror of the outgoing-call table: tracks in-flight incoming calls by
* request id so an inbound `CancellationRequest` can cancel the running handler.
*/
/* @internal */
export class IncomingCallTable {
private readonly _map = new Map<string, CancellationTokenSource>();

/** Tracks `requestId`; the returned token fires when a cancellation for it arrives. */
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();
}
}
7 changes: 7 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,8 @@ 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. */
public readonly ct: CancellationToken = CancellationToken.none,
) {}
}

Expand All @@ -29,5 +31,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