commit ea4d0e3eb96997c9e036c4cc3d7e7edec3fbec39
parent f6fa69844bc790b6dbec56f88848254870b1326e
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 20:43:16 +0200
util: fix duration parsing and release fired timers
fromPrettyString dropped a trailing number that carried no unit, so "1h 30"
parsed as one hour.
TimerGroup kept the handle of a one-shot timer after it had fired, so the map
grew for the lifetime of the group, and resolveAfter scheduled a timer even
for an already-cancelled token.
Diffstat:
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
@@ -229,6 +229,7 @@ export namespace Duration {
let dMs = 0;
let currentNum = "";
let parsingNum = true;
+ let sawUnit = false;
for (let i = 0; i < s.length; i++) {
const cc = s.charCodeAt(i);
if (cc >= "0".charCodeAt(0) && cc <= "9".charCodeAt(0)) {
@@ -272,9 +273,17 @@ export namespace Duration {
} else {
throw Error("invalid duration, unsupported unit");
}
+ sawUnit = true;
currentNum = "";
parsingNum = true;
}
+ // A number is only added when its unit is seen; anything left has none.
+ if (currentNum !== "") {
+ throw Error("invalid duration, number without unit");
+ }
+ if (dMs === 0 && !sawUnit) {
+ throw Error("invalid duration, no unit given");
+ }
return {
d_ms: dMs,
};
diff --git a/packages/taler-util/src/timer.ts b/packages/taler-util/src/timer.ts
@@ -173,6 +173,11 @@ export class TimerGroup {
resolve(false);
});
}
+ // onCancelled fires synchronously for an already-cancelled token, so
+ // the promise may have settled; a timer scheduled now is an orphan.
+ if (cancellationToken?.isCancelled) {
+ return;
+ }
if (delayMs.d_ms !== "forever") {
timerHandle = this.after(delayMs.d_ms, () => {
unregisterCt?.();
@@ -187,11 +192,14 @@ export class TimerGroup {
logger.warn("dropping timer since timer group is stopped");
return nullTimerHandle;
}
- const h = this.timerApi.after(delayMs, callback);
const myId = this.idGen++;
- this.timerMap[myId] = h;
-
const tm = this.timerMap;
+ // A one-shot timer is dead once it has fired; drop it from the map.
+ const h = this.timerApi.after(delayMs, () => {
+ delete tm[myId];
+ callback();
+ });
+ this.timerMap[myId] = h;
return {
clear() {