{"record":{"id":"5a2344647adf115d","repo":"dotnet/runtime","slug":"expected-a-controllable-promise","errorCode":null,"errorMessage":"Expected a controllable promise.","messagePattern":"Expected a controllable promise\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshaled-types.ts","lineNumber":235,"sourceCode":"        }\n        dotnetAssert.check(!this.isResolved, \"cancel could be called only once\");\n        dotnetAssert.check(!this.isDisposed, \"resolve is already disposed.\");\n\n        if (this.isPostponed) {\n            // there was racing resolve/reject which was postponed, to retain valid GCHandle\n            // in this case we just finish the original resolve/reject\n            // and we need to use the postponed data/reason\n            this.isResolved = true;\n            if (this.reason !== undefined) {\n                this.completeTaskWrapper(null, this.reason);\n            } else {\n                this.completeTaskWrapper(this.data, null);\n            }\n        } else {\n            // there is no racing resolve/reject, we can reject/cancel the promise\n            const promise = this.promise;\n            if (!dotnetLoaderExports.isControllablePromise(promise)) {\n                throw new Error(\"Expected a controllable promise.\");\n            }\n            const pcs = dotnetLoaderExports.getPromiseCompletionSource(promise);\n\n            const reason = new Error(\"OperationCanceledException\") as any;\n            reason[promiseHolderSymbol] = this;\n            pcs.reject(reason);\n        }\n    }\n\n    // we can do this just once, because it will be dispose the GCHandle\n    completeTaskWrapper(data: any, reason: any) {\n        try {\n            dotnetAssert.check(!this.isPosted, \"Promise is already posted to managed.\");\n            this.isPosted = true;\n\n            // we can unregister the GC handle just on JS side\n            teardownManagedProxy(this, this.gc_handle, /*skipManaged: */ true);\n            // order of operations with teardown_managed_proxy matters","sourceCodeStart":217,"sourceCodeEnd":253,"githubUrl":"https://github.com/dotnet/runtime/blob/290d5ab72cc1102813fbc0406fb186ceadabc340/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshaled-types.ts#L217-L253","documentation":"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.","triggerScenarios":"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.","commonSituations":"Mixing loader/runtime versions; manually wrapping interop promises before they resolve; a race where the promise holder is reused after disposal.","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."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Validate controllability before canceling (uses loader exports if exposed)\nfunction canCancel(promise: Promise<unknown>, loader: any): boolean {\n    return loader?.isControllablePromise ? loader.isControllablePromise(promise) : false;\n}","typeGuard":null,"tryCatchPattern":"try {\n    cancelPromise(taskGCHandle);\n} catch (e) {\n    if (String(e).includes(\"controllable promise\")) {\n        // promise is no longer cancelable from JS; ignore or log\n        console.warn(\"Task not cancelable from JS\", e);\n    } else throw e;\n}","preventionTips":["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."],"tags":["promise","task","cancellation","invariant","internal"],"analyzedSha":"290d5ab72cc1102813fbc0406fb186ceadabc340","analyzedAt":"2026-08-06T19:57:01.276Z","schemaVersion":2},"datasetVersion":"2026-08-06T23:17:07.152Z"}