commit dbcf19b6c15df3a5574dd67079422e9b33db0086
parent a9726106bec47d4c2caa9c7b49c3a1fc54599a44
Author: Iván Ávalos <avalos@disroot.org>
Date: Fri, 21 Aug 2026 15:41:44 +0200
wallet-core: roll back a transaction whose commit failed on the native backend
Diffstat:
2 files changed, 54 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/db/sqlite/database.ts b/packages/taler-wallet-core/src/db/sqlite/database.ts
@@ -385,7 +385,21 @@ async function runNativeSqliteWalletTxLocked<T>(
}
throw e;
}
- await ndb.txc.commit();
+ try {
+ await ndb.txc.commit();
+ } catch (e) {
+ // A COMMIT can fail on its own: deferred foreign keys are checked
+ // here, not when the row was written. Leaving it at that keeps the
+ // connection *inside* the transaction, and every transaction after it
+ // fails with "cannot start a transaction within a transaction" -- one
+ // bad write and the wallet's database is unusable until it restarts.
+ try {
+ await ndb.txc.rollback();
+ } catch (rollbackErr) {
+ logger.warn(`rollback after a failed commit failed: ${rollbackErr}`);
+ }
+ throw e;
+ }
await checkpointIfIdle(ndb);
// Same order as the IndexedDB backend: the handlers run before a client
// can observe the notification.
diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts
@@ -33,6 +33,7 @@ import { BLOB_COLUMNS } from "./schema.js";
import { conformanceCases } from "../testing/conformance-cases.js";
import { makeSqliteRunner } from "../testing/runners.js";
import { initSqliteWalletDb } from "./database.js";
+import { AmountString } from "@gnu-taler/taler-util";
/**
* Run every conformance case against one database, then inspect how the
@@ -178,3 +179,41 @@ test("sqlite: schema constraints reject invalid rows", async () => {
await db.close();
});
+
+test("sqlite: a failed commit does not poison the connection", async () => {
+ // Deferred foreign keys are checked at COMMIT, not when the row is
+ // written, so a transaction can fail after its body has run. Leaving it
+ // there keeps the connection inside the transaction, and every
+ // transaction afterwards fails with "cannot start a transaction within a
+ // transaction" -- one bad write and the wallet is unusable until it
+ // restarts.
+ const runner = await makeSqliteRunner();
+ try {
+ await assert.rejects(
+ runner.runReadWriteTx(async (tx) => {
+ await tx.upsertRefreshSession({
+ refreshGroupId: "no-such-group",
+ coinIndex: 0,
+ amountRefreshOutput: "TESTKUDOS:1" as AmountString,
+ newDenoms: [],
+ });
+ }),
+ /FOREIGN KEY/,
+ "an orphan session must be refused at commit",
+ );
+
+ // The point of the test: the database still works.
+ const coins = await runner.runReadWriteTx((tx) => tx.listAllCoins());
+ assert.strictEqual(coins.length, 0);
+ // ... and still takes writes.
+ await runner.runReadWriteTx((tx) =>
+ tx.upsertTombstone({ id: "tmb:test:1" }),
+ );
+ const tombstones = await runner.runReadWriteTx((tx) =>
+ tx.listAllTombstones(),
+ );
+ assert.strictEqual(tombstones.length, 1);
+ } finally {
+ await runner.close();
+ }
+});