dotnet/runtime · error · Error

Expected a controllable promise.

Error message

Expected a controllable promise.

What it means

Thrown by PromiseHolder.cancel when the stored promise is not recognized as a 'controllable promise' by the loader's isControllablePromise check. The cancellation path needs a PromiseCompletionSource attached to the promise; if the promise was replaced/wrapped or came from a different runtime component, the invariant fails. This signals a logic error in how the Task/promise was wired, not a user input problem.

Source

Thrown at src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshaled-types.ts:235

        }
        dotnetAssert.check(!this.isResolved, "cancel could be called only once");
        dotnetAssert.check(!this.isDisposed, "resolve is already disposed.");

        if (this.isPostponed) {
            // there was racing resolve/reject which was postponed, to retain valid GCHandle
            // in this case we just finish the original resolve/reject
            // and we need to use the postponed data/reason
            this.isResolved = true;
            if (this.reason !== undefined) {
                this.completeTaskWrapper(null, this.reason);
            } else {
                this.completeTaskWrapper(this.data, null);
            }
        } else {
            // there is no racing resolve/reject, we can reject/cancel the promise
            const promise = this.promise;
            if (!dotnetLoaderExports.isControllablePromise(promise)) {
                throw new Error("Expected a controllable promise.");
            }
            const pcs = dotnetLoaderExports.getPromiseCompletionSource(promise);

            const reason = new Error("OperationCanceledException") as any;
            reason[promiseHolderSymbol] = this;
            pcs.reject(reason);
        }
    }

    // we can do this just once, because it will be dispose the GCHandle
    completeTaskWrapper(data: any, reason: any) {
        try {
            dotnetAssert.check(!this.isPosted, "Promise is already posted to managed.");
            this.isPosted = true;

            // we can unregister the GC handle just on JS side
            teardownManagedProxy(this, this.gc_handle, /*skipManaged: */ true);
            // order of operations with teardown_managed_proxy matters

View on GitHub (pinned to 290d5ab72c)

Solutions

  1. Do not wrap or replace promises returned by the interop layer; let the runtime own them.
  2. Ensure loader and runtime JS are from the same build (matching gitHash).
  3. If canceling, cancel through the C# CancellationToken rather than manipulating JS promises directly.
Defensive patterns

Strategy: try-catch

Validate before calling

// Validate controllability before canceling (uses loader exports if exposed)
function canCancel(promise: Promise<unknown>, loader: any): boolean {
    return loader?.isControllablePromise ? loader.isControllablePromise(promise) : false;
}

Try / catch

try {
    cancelPromise(taskGCHandle);
} catch (e) {
    if (String(e).includes("controllable promise")) {
        // promise is no longer cancelable from JS; ignore or log
        console.warn("Task not cancelable from JS", e);
    } else throw e;
}

Prevention

When it happens

Trigger: A managed Task passed back to JS for cancellation where the underlying JS promise is not the controllable wrapper the runtime created (e.g. it was replaced with a native Promise.resolve, double-wrapped, or came from a mismatched loader). Triggered when C# requests cancellation of such a task.

Common situations: Mixing loader/runtime versions; manually wrapping interop promises before they resolve; a race where the promise holder is reused after disposal.

Related errors


AI-assisted analysis of dotnet/runtime@290d5ab72c (2026-08-06). Data as JSON: /api/errors/5a2344647adf115d. Report an issue: GitHub.