taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 19c30e61b252bec5bfaa3d65fc7900764a628467
parent 32de0432c0a9f050cba1eb44d93388fe28687a67
Author: Florian Dold <dold@taler.net>
Date:   Tue, 28 Jul 2026 23:06:35 +0200

util: keep the typed timeout and cancellation errors for native requests

cancelFn() rejects the native promise synchronously, so cancelling before
rejecting let Promise.race settle on the platform's own error, and a qtart
timeout surfaced as a plain cancellation instead of a timeout.

Issue: https://bugs.taler.net/n/9822

Diffstat:
Mpackages/taler-util/src/http-common.ts | 8++++++--
Mpackages/taler-util/src/http-native-request.test.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 58 insertions(+), 2 deletions(-)

diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -95,17 +95,21 @@ export async function awaitNativeRequest<T>( } }; + // Reject before cancelling, not after: cancelFn() makes the native promise + // reject synchronously, and Promise.race keeps whichever settled first, so + // cancelling first surfaces the platform's own cancellation error and the + // typed error below is lost. if (opts.timeout && opts.timeout.d_ms !== "forever") { timeoutHandle = setTimeout(() => { - native.cancelFn(); abortCap.reject(new RequestTimeoutError()); + native.cancelFn(); }, opts.timeout.d_ms); } if (opts.cancellationToken) { unregisterCancel = opts.cancellationToken.onCancelled(() => { - native.cancelFn(); abortCap.reject(new RequestCancelledError()); + native.cancelFn(); }); } diff --git a/packages/taler-util/src/http-native-request.test.ts b/packages/taler-util/src/http-native-request.test.ts @@ -96,3 +96,55 @@ test("no armed-timer leak: the timeout does not fire after the request settled", await new Promise((r) => setTimeout(r, 60)); assert.strictEqual(s.cancels, 0); }); + +/** + * Like stubNative, but cancelFn rejects the in-flight promise synchronously, + * the way the qtart runtime actually behaves. + */ +function stubNativeRejectingOnCancel<T>() { + let cancels = 0; + let rejectFn: (e: unknown) => void = () => undefined; + const promise = new Promise<T>((_resolve, reject) => { + rejectFn = reject; + }); + return { + native: { + promise, + cancelFn: () => { + cancels++; + rejectFn(new Error("HTTP request cancelled")); + }, + }, + get cancels() { + return cancels; + }, + }; +} + +async function rejectionOf(p: Promise<unknown>): Promise<any> { + try { + await p; + } catch (e) { + return e; + } + throw new Error("expected the promise to reject"); +} + +test("timeout reports a timeout even when cancelling rejects the native request", async (t) => { + const s = stubNativeRejectingOnCancel(); + const e = await rejectionOf( + awaitNativeRequest(s.native, { timeout: Duration.fromMilliseconds(20) }), + ); + assert.strictEqual(e.name, "RequestTimeoutError"); + assert.strictEqual(s.cancels, 1); +}); + +test("cancellation reports a cancellation even when cancelling rejects the native request", async (t) => { + const s = stubNativeRejectingOnCancel(); + const src = CancellationToken.create(); + const p = awaitNativeRequest(s.native, { cancellationToken: src.token }); + src.cancel(); + const e = await rejectionOf(p); + assert.strictEqual(e.name, "RequestCancelledError"); + assert.strictEqual(s.cancels, 1); +});