From 24b71107765172b568803fad5fb79474674b147a Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 28 Mar 2022 20:23:47 +0200 Subject: [PATCH] vendor CancellationToken --- packages/taler-util/src/CancellationToken.ts | 285 ++++++++++++++++++ packages/taler-util/src/index.ts | 1 + packages/taler-wallet-cli/package.json | 1 - .../src/integrationtests/testrunner.ts | 85 +++--- packages/taler-wallet-core/src/util/http.ts | 12 + 5 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 packages/taler-util/src/CancellationToken.ts diff --git a/packages/taler-util/src/CancellationToken.ts b/packages/taler-util/src/CancellationToken.ts new file mode 100644 index 000000000..134805274 --- /dev/null +++ b/packages/taler-util/src/CancellationToken.ts @@ -0,0 +1,285 @@ +/* +MIT License + +Copyright (c) 2017 Conrad Reuter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +const NOOP = () => {}; + +/** + * A token that can be passed around to inform consumers of the token that a + * certain operation has been cancelled. + */ +class CancellationToken { + private _reason: any; + private _callbacks?: Set<(reason?: any) => void> = new Set(); + + /** + * A cancellation token that is already cancelled. + */ + public static readonly CANCELLED: CancellationToken = new CancellationToken( + true, + true, + ); + + /** + * A cancellation token that is never cancelled. + */ + public static readonly CONTINUE: CancellationToken = new CancellationToken( + false, + false, + ); + + /** + * Whether the token has been cancelled. + */ + public get isCancelled(): boolean { + return this._isCancelled; + } + + /** + * Whether the token can be cancelled. + */ + public get canBeCancelled(): boolean { + return this._canBeCancelled; + } + + /** + * Why this token has been cancelled. + */ + public get reason(): any { + if (this.isCancelled) { + return this._reason; + } else { + throw new Error("This token is not cancelled."); + } + } + + /** + * Make a promise that resolves when the async operation resolves, + * or rejects when the operation is rejected or this token is cancelled. + */ + public racePromise(asyncOperation: Promise): Promise { + if (!this.canBeCancelled) { + return asyncOperation; + } + return new Promise((resolve, reject) => { + // we could use Promise.finally here as soon as it's implemented in the major browsers + const unregister = this.onCancelled((reason) => + reject(new CancellationToken.CancellationError(reason)), + ); + asyncOperation.then( + (value) => { + resolve(value); + unregister(); + }, + (err) => { + reject(err); + unregister(); + }, + ); + }); + } + + /** + * Throw a {CancellationToken.CancellationError} if this token is cancelled. + */ + public throwIfCancelled(): void { + if (this._isCancelled) { + throw new CancellationToken.CancellationError(this._reason); + } + } + + /** + * Invoke the callback when this token is cancelled. + * If this token is already cancelled, the callback is invoked immediately. + * Returns a function that unregisters the cancellation callback. + */ + public onCancelled(cb: (reason?: any) => void): () => void { + if (!this.canBeCancelled) { + return NOOP; + } + if (this.isCancelled) { + cb(this.reason); + return NOOP; + } + + /* istanbul ignore next */ + this._callbacks?.add(cb); + return () => this._callbacks?.delete(cb); + } + + private constructor( + /** + * Whether the token is already cancelled. + */ + private _isCancelled: boolean, + /** + * Whether the token can be cancelled. + */ + private _canBeCancelled: boolean, + ) {} + + /** + * Create a {CancellationTokenSource}. + */ + public static create(): CancellationToken.Source { + const token = new CancellationToken(false, true); + + const cancel = (reason?: any) => { + if (token._isCancelled) return; + token._isCancelled = true; + token._reason = reason; + token._callbacks?.forEach((cb) => cb(reason)); + dispose(); + }; + + const dispose = () => { + token._canBeCancelled = token.isCancelled; + delete token._callbacks; // release memory + }; + + return { token, cancel, dispose }; + } + + /** + * Create a {CancellationTokenSource}. + * The token will be cancelled automatically after the specified timeout in milliseconds. + */ + public static timeout(ms: number): CancellationToken.Source { + const { + token, + cancel: originalCancel, + dispose: originalDispose, + } = CancellationToken.create(); + + let timer: NodeJS.Timeout | null; + timer = setTimeout(() => originalCancel(CancellationToken.timeout), ms); + const disposeTimer = () => { + if (timer == null) return; + clearTimeout(timer); + timer = null; + }; + + const cancel = (reason?: any) => { + disposeTimer(); + originalCancel(reason); + }; + + /* istanbul ignore next */ + const dispose = () => { + disposeTimer(); + originalDispose(); + }; + + return { token, cancel, dispose }; + } + + /** + * Create a {CancellationToken} that is cancelled when all of the given tokens are cancelled. + * + * This is like {Promise.all} for {CancellationToken}s. + */ + public static all(...tokens: CancellationToken[]): CancellationToken { + // If *any* of the tokens cannot be cancelled, then the token we return can never be. + if (tokens.some((token) => !token.canBeCancelled)) { + return CancellationToken.CONTINUE; + } + + const combined = CancellationToken.create(); + let countdown = tokens.length; + const handleNextTokenCancelled = () => { + if (--countdown === 0) { + const reasons = tokens.map((token) => token._reason); + combined.cancel(reasons); + } + }; + tokens.forEach((token) => token.onCancelled(handleNextTokenCancelled)); + return combined.token; + } + + /** + * Create a {CancellationToken} that is cancelled when at least one of the given tokens is cancelled. + * + * This is like {Promise.race} for {CancellationToken}s. + */ + public static race(...tokens: CancellationToken[]): CancellationToken { + // If *any* of the tokens is already cancelled, immediately return that token. + for (const token of tokens) { + if (token._isCancelled) { + return token; + } + } + + const combined = CancellationToken.create(); + let unregistrations: (() => void)[]; + const handleAnyTokenCancelled = (reason?: any) => { + unregistrations.forEach((unregister) => unregister()); // release memory + combined.cancel(reason); + }; + unregistrations = tokens.map((token) => + token.onCancelled(handleAnyTokenCancelled), + ); + return combined.token; + } +} + +/* istanbul ignore next */ +namespace CancellationToken { + /** + * Provides a {CancellationToken}, along with some methods to operate on it. + */ + export interface Source { + /** + * The token provided by this source. + */ + token: CancellationToken; + + /** + * Cancel the provided token with the given reason. + * Do nothing if the provided token cannot be cancelled or is already cancelled. + */ + cancel(reason?: any): void; + + /** + * Dipose of the token and this source and release memory. + */ + dispose(): void; + } + + /** + * The error that is thrown when a {CancellationToken} has been cancelled and a + * consumer of the token calls {CancellationToken.throwIfCancelled} on it. + */ + export class CancellationError extends Error { + public constructor( + /** + * The reason why the token was cancelled. + */ + public readonly reason: any, + ) { + super("Operation cancelled"); + Object.setPrototypeOf(this, CancellationError.prototype); + } + } +} + +export { CancellationToken }; diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 573b4a5c7..199218d69 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -31,3 +31,4 @@ export { crypto_sign_keyPair_fromSeed, } from "./nacl-fast.js"; export { RequestThrottler } from "./RequestThrottler.js"; +export * from "./CancellationToken.js"; diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json index 96f699391..e43090560 100644 --- a/packages/taler-wallet-cli/package.json +++ b/packages/taler-wallet-cli/package.json @@ -47,7 +47,6 @@ "@gnu-taler/taler-util": "workspace:*", "@gnu-taler/taler-wallet-core": "workspace:*", "axios": "^0.25.0", - "cancellationtoken": "^2.2.0", "source-map-support": "^0.5.21", "tslib": "^2.3.1" } diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index 3839266c0..d8dc569d2 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -14,80 +14,79 @@ GNU Taler; see the file COPYING. If not, see */ -import { minimatch } from "@gnu-taler/taler-util"; +import { CancellationToken, minimatch } from "@gnu-taler/taler-util"; +import * as child_process from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; import { GlobalTestState, runTestWithState, shouldLingerInTest, TestRunResult, } from "../harness/harness.js"; -import { runPaymentTest } from "./test-payment"; -import { runPaymentDemoTest } from "./test-payment-on-demo"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; -import * as child_process from "child_process"; import { runBankApiTest } from "./test-bank-api"; import { runClaimLoopTest } from "./test-claim-loop"; +import { runClauseSchnorrTest } from "./test-clause-schnorr.js"; +import { runDenomUnofferedTest } from "./test-denom-unoffered.js"; +import { runDepositTest } from "./test-deposit"; import { runExchangeManagementTest } from "./test-exchange-management"; +import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; import { runFeeRegressionTest } from "./test-fee-regression"; +import { runLibeufinApiBankaccountTest } from "./test-libeufin-api-bankaccount"; +import { runLibeufinApiBankconnectionTest } from "./test-libeufin-api-bankconnection"; +import { runLibeufinApiFacadeTest } from "./test-libeufin-api-facade"; +import { runLibeufinApiFacadeBadRequestTest } from "./test-libeufin-api-facade-bad-request"; +import { runLibeufinApiPermissionsTest } from "./test-libeufin-api-permissions"; +import { runLibeufinApiSandboxCamtTest } from "./test-libeufin-api-sandbox-camt"; +import { runLibeufinApiSandboxTransactionsTest } from "./test-libeufin-api-sandbox-transactions"; +import { runLibeufinApiSchedulingTest } from "./test-libeufin-api-scheduling"; +import { runLibeufinApiUsersTest } from "./test-libeufin-api-users"; +import { runLibeufinBadGatewayTest } from "./test-libeufin-bad-gateway"; +import { runLibeufinBasicTest } from "./test-libeufin-basic"; +import { runLibeufinC5xTest } from "./test-libeufin-c5x"; +import { runLibeufinAnastasisFacadeTest } from "./test-libeufin-facade-anastasis"; +import { runLibeufinKeyrotationTest } from "./test-libeufin-keyrotation"; +import { runLibeufinNexusBalanceTest } from "./test-libeufin-nexus-balance"; +import { runLibeufinRefundTest } from "./test-libeufin-refund"; +import { runLibeufinRefundMultipleUsersTest } from "./test-libeufin-refund-multiple-users"; +import { runLibeufinSandboxWireTransferCliTest } from "./test-libeufin-sandbox-wire-transfer-cli"; +import { runLibeufinTutorialTest } from "./test-libeufin-tutorial"; +import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion"; +import { runMerchantInstancesTest } from "./test-merchant-instances"; +import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete"; +import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls"; import { runMerchantLongpollingTest } from "./test-merchant-longpolling"; import { runMerchantRefundApiTest } from "./test-merchant-refund-api"; +import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js"; import { runPayAbortTest } from "./test-pay-abort"; import { runPayPaidTest } from "./test-pay-paid"; +import { runPaymentTest } from "./test-payment"; import { runPaymentClaimTest } from "./test-payment-claim"; import { runPaymentFaultTest } from "./test-payment-fault"; +import { runPaymentForgettableTest } from "./test-payment-forgettable.js"; import { runPaymentIdempotencyTest } from "./test-payment-idempotency"; import { runPaymentMultipleTest } from "./test-payment-multiple"; +import { runPaymentDemoTest } from "./test-payment-on-demo"; import { runPaymentTransientTest } from "./test-payment-transient"; +import { runPaymentZeroTest } from "./test-payment-zero.js"; import { runPaywallFlowTest } from "./test-paywall-flow"; +import { runRefundTest } from "./test-refund"; import { runRefundAutoTest } from "./test-refund-auto"; import { runRefundGoneTest } from "./test-refund-gone"; import { runRefundIncrementalTest } from "./test-refund-incremental"; -import { runRefundTest } from "./test-refund"; import { runRevocationTest } from "./test-revocation"; import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh"; import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw"; import { runTippingTest } from "./test-tipping"; +import { runWalletBackupBasicTest } from "./test-wallet-backup-basic"; +import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend"; +import { runWalletDblessTest } from "./test-wallet-dbless.js"; import { runWallettestingTest } from "./test-wallettesting"; -import { runTestWithdrawalManualTest } from "./test-withdrawal-manual"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated"; -import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion"; -import { runLibeufinBasicTest } from "./test-libeufin-basic"; -import { runLibeufinC5xTest } from "./test-libeufin-c5x"; -import { runLibeufinNexusBalanceTest } from "./test-libeufin-nexus-balance"; -import { runLibeufinBadGatewayTest } from "./test-libeufin-bad-gateway"; -import { runLibeufinKeyrotationTest } from "./test-libeufin-keyrotation"; -import { runLibeufinRefundTest } from "./test-libeufin-refund"; -import { runLibeufinRefundMultipleUsersTest } from "./test-libeufin-refund-multiple-users"; -import { runLibeufinTutorialTest } from "./test-libeufin-tutorial"; -import { runLibeufinApiPermissionsTest } from "./test-libeufin-api-permissions"; -import { runLibeufinApiFacadeTest } from "./test-libeufin-api-facade"; -import { runLibeufinApiFacadeBadRequestTest } from "./test-libeufin-api-facade-bad-request"; -import { runLibeufinAnastasisFacadeTest } from "./test-libeufin-facade-anastasis"; -import { runLibeufinApiSchedulingTest } from "./test-libeufin-api-scheduling"; -import { runLibeufinApiBankconnectionTest } from "./test-libeufin-api-bankconnection"; -import { runLibeufinApiUsersTest } from "./test-libeufin-api-users"; -import { runLibeufinApiBankaccountTest } from "./test-libeufin-api-bankaccount"; -import { runLibeufinApiSandboxTransactionsTest } from "./test-libeufin-api-sandbox-transactions"; -import { runLibeufinApiSandboxCamtTest } from "./test-libeufin-api-sandbox-camt"; -import { runLibeufinSandboxWireTransferCliTest } from "./test-libeufin-sandbox-wire-transfer-cli"; -import { runDepositTest } from "./test-deposit"; -import CancellationToken from "cancellationtoken"; -import { runMerchantInstancesTest } from "./test-merchant-instances"; -import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls"; -import { runWalletBackupBasicTest } from "./test-wallet-backup-basic"; -import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete"; -import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend"; -import { runPaymentForgettableTest } from "./test-payment-forgettable.js"; -import { runPaymentZeroTest } from "./test-payment-zero.js"; -import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js"; -import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; -import { runDenomUnofferedTest } from "./test-denom-unoffered.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; -import { runClauseSchnorrTest } from "./test-clause-schnorr.js"; -import { runWalletDblessTest } from "./test-wallet-dbless.js"; +import { runTestWithdrawalManualTest } from "./test-withdrawal-manual"; /** * Test runner. diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index 31e38b609..9ccd560d9 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -31,6 +31,7 @@ import { TalerErrorDetail, Codec, j2s, + CancellationToken, } from "@gnu-taler/taler-util"; import { TalerErrorCode } from "@gnu-taler/taler-util"; import { makeErrorDetail, TalerError } from "../errors.js"; @@ -53,7 +54,18 @@ export interface HttpResponse { export interface HttpRequestOptions { method?: "POST" | "PUT" | "GET"; headers?: { [name: string]: string }; + + /** + * Timeout after which the request should be aborted. + */ timeout?: Duration; + + /** + * Cancellation token that should abort the request when + * cancelled. + */ + cancellationToken?: CancellationToken; + body?: string | ArrayBuffer | ArrayBufferView; }