Skip to content

Conversation

@kriskowal
Copy link
Member

@kriskowal kriskowal commented Dec 31, 2025

Description

This PR introduces a new @endo/cancel package that provides Promise<never>-based cancellation tokens with synchronous observation capability, designed for use with Endo and CapTP.

Core API

  • makeCancelKit(parentCancelled?) - Creates a { cancelled, cancel } pair where cancelled is a Promise<never> with a synchronous .cancelled getter. Supports hierarchical cancellation via optional parent token.

Operators

  • allMap(values, fn, parentCancelled) - Maps over values with cancellation propagation; cancels all on first rejection
  • anyMap(values, fn, parentCancelled?) - Races cancellable jobs; cancels remaining after first success
  • delay(ms, parentCancelled) - Cancellable delay using ambient setTimeout
  • makeDelay(setTimeout, clearTimeout) - Factory for delay with injectable timer functions (from delay-lite)

Web API Integration

  • toAbortSignal(cancelled) - Converts CancelledAbortSignal for use with fetch and other web APIs
  • fromAbortSignal(signal) - Converts AbortSignalCancelled for integrating web cancellation sources

Adoption in Daemon and CLI

This PR also refactors @endo/daemon and @endo/cli to use makeCancelKit instead of the previous pattern of using makePromiseKit for cancellation. This demonstrates the ergonomic improvements and validates the design against real-world usage.

Pass-Style Relaxation

This PR includes a surgical change to @endo/pass-style that allows promises with a cancelled getter to be passable.

Previously, safe-promise.js rejected any promise with string-named own properties. This change adds a specific exception for cancelled:

// packages/pass-style/src/safe-promise.js
if (key === 'cancelled') {
  // Allow a non-enumerable `cancelled` getter for cancellation tokens.
  // This property is local-only and will not be passed over CapTP.
  const cancelledDesc = getOwnPropertyDescriptor(pr, 'cancelled');
  assert(cancelledDesc !== undefined);
  return (
    (hasOwn(cancelledDesc, 'get') ||
      (reject &&
        reject`Own 'cancelled' must be an accessor property, not a data property: ${q(cancelledDesc)}`)) &&
    (!cancelledDesc.enumerable ||
      (reject &&
        reject`Own 'cancelled' must not be enumerable: ${q(cancelledDesc)}`))
  );
}

Constraints enforced:

  1. Must be an accessor property (getter), not a data property
  2. Must be non-enumerable

Why this is safe:

  • When promises are serialized over CapTP/marshal, only a slot reference is passed (e.g., "&0")
  • The cancelled property is naturally omitted during serialization—it never crosses the wire
  • The getter provides local-only synchronous observation, which is the intended design

Rationale:

  • Enables Cancelled tokens (promises with .cancelled getter) to be passed as arguments to remote methods
  • The remote side receives a normal promise that rejects on cancellation
  • The local side retains synchronous observability for performance-critical checks

Security Considerations

  • All exports are hardened with harden()
  • The synchronous .cancelled getter is intentionally non-passable over CapTP—only the promise behavior crosses boundaries
  • The pass-style relaxation is narrowly scoped: only a non-enumerable cancelled getter is allowed, no other own properties

Scaling Considerations

None.

Documentation Considerations

Provides README and DESIGN.

Testing Considerations

  • Tests with coverage of all functions in @endo/cancel
  • All 112 daemon tests pass
  • All 10 CLI tests pass

Compatibility Considerations

  • Designed to integrate with existing CapTP infrastructure
  • Web API integration enables gradual adoption alongside AbortController
  • The pass-style change is backward compatible—existing promises without cancelled continue to work unchanged

Upgrade Considerations

Packages using the makePromiseKit pattern for cancellation may optionally migrate to makeCancelKit for improved ergonomics and the synchronous .cancelled getter.

@kriskowal
Copy link
Member Author

This may interest @gibson042. By way of disclosure, I did make extensive use of Claude 4.5 Opus to produce this very quickly, and watched it closely. This is produced more as an exercise to validate that the stochastic parrot might make some exercises, otherwise hard to get around to, more achievable in short order.

@kriskowal kriskowal force-pushed the kriskowal-cancel branch 2 times, most recently from 30549aa to 0431e29 Compare December 31, 2025 10:31
@kriskowal
Copy link
Member Author

Aside, I noticed an opportunity to use makePromiseKit, but that exercise is dashed because that produces a hardened promise, and so we cannot introduce a new cancelled property.

@kriskowal kriskowal requested a review from erights December 31, 2025 23:42
@kriskowal kriskowal marked this pull request as ready for review January 1, 2026 04:31
@kriskowal kriskowal requested a review from rekmarks January 1, 2026 04:31
Copy link
Contributor

@erights erights left a comment

Choose a reason for hiding this comment

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

Please look at #3035 . It is a failed experiment for a different approach -- use a static function rather than a getter. But it has a fatal security flaw that it explains. In any case, the exercise gave me a full understanding of yours. I'm ready to approve, but only after discussing #3035.

This is a cool project, thanks!

@kriskowal kriskowal added the agenda Topics for next Endo meeting agenda label Jan 4, 2026
@kriskowal
Copy link
Member Author

Another consideration we should contemplate: isCancelled(error) classifier, or simply an error.cancelled conventional property.

We should also think about graceful shutdown. There are cases where shutdown might be a gentler signal that leads cancelled, where the expectation is that an operation that’s shut down will return gracefully, unless it takes too long and cancelled propagates. You wouldn’t thread your shutdown signal to every underlying thing, in the way you would thread cancelled, but you would reject new work after you receive it. I suspect that our test flakes in Dæmon occur due to the race to shut down or cancel not being threaded correctly.

Copy link
Contributor

@rekmarks rekmarks left a comment

Choose a reason for hiding this comment

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

It's good! Some non-blocking feedback.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like this warrants a couple of test cases in test/safe-promise.test.js.

const { cancelled, cancel } = makeCancelKit();

// Check synchronously if cancelled
console.log(cancelled.cancelled); // undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

Not false?

const { cancelled: childCancelled } = makeCancelKit(parentCancelled);

cancelParent(Error('Parent cancelled'));
// childCancelled is now also cancelled
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// childCancelled is now also cancelled
console.log(childCancelled.cancelled) // true

Comment on lines +244 to +266
## TypeScript Types

```ts
// The cancellation token type
type Cancelled = Promise<never> & { readonly cancelled: undefined | true };

// The cancel function type
type Cancel = (reason?: Error) => void;

// The result of makeCancelKit()
type CancelKit = {
cancelled: Cancelled;
cancel: Cancel;
};

// Callback signature for allMap and anyMap
type CancellableCallback<T, R> = (
value: T,
index: number,
cancelled: Cancelled
) => R | Promise<R>;
```

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
## TypeScript Types
```ts
// The cancellation token type
type Cancelled = Promise<never> & { readonly cancelled: undefined | true };
// The cancel function type
type Cancel = (reason?: Error) => void;
// The result of makeCancelKit()
type CancelKit = {
cancelled: Cancelled;
cancel: Cancel;
};
// Callback signature for allMap and anyMap
type CancellableCallback<T, R> = (
value: T,
index: number,
cancelled: Cancelled
) => R | Promise<R>;
```

Copy link
Contributor

Choose a reason for hiding this comment

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

This mainly seems to restate the readme. Delete?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agenda Topics for next Endo meeting agenda

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants