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 mattersView on GitHub (pinned to 290d5ab72c)
Solutions
- Do not wrap or replace promises returned by the interop layer; let the runtime own them.
- Ensure loader and runtime JS are from the same build (matching gitHash).
- 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
- Cancel tasks through the C# CancellationToken, not by manipulating JS promises.
- Do not wrap or replace interop-returned promises.
- Keep loader and runtime versions matched.
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
- NotImplementedException
- OperationCanceledException
- OperationCanceledException
- Expected internals to be an array
- Expected internals to have RuntimeAPI
AI-assisted analysis of dotnet/runtime@290d5ab72c (2026-08-06).
Data as JSON: /api/errors/5a2344647adf115d.
Report an issue: GitHub.