From a306c54d6f749f2e00f54274dcbe93a197c414cd Mon Sep 17 00:00:00 2001 From: Andrea Saiani Date: Fri, 29 May 2026 21:30:36 +0200 Subject: [PATCH] refactor: switch worker timers from setInterval to recursive setTimeout (#2522) Following the pattern from oisy-wallet#9706: each worker now schedules the next tick only after the previous async callback resolves, so the sync cannot overlap itself. With setInterval, a callback slower than the interval would queue overlapping calls (race conditions, duplicated network requests). Applied across all 8 worker timers: - workers/auth.worker.ts - workers/icp-cycles-rate.worker.ts - workers/cycles.worker.ts - workers/exchange.worker.ts - workers/hosting.worker.ts - workers/wallet.worker.ts - workers/workflows.worker.ts - workers/monitoring.worker.ts Each start function now early-returns if a timer is already running (`nonNullish(timer)`), matching the OISY pattern. clearInterval is swapped for clearTimeout. Now-redundant overlap guards are removed: - `let syncing = false` plus its guard/assignments in icp-cycles-rate, cycles, exchange, hosting, monitoring workers. - `syncing: Record<...,boolean>` plus per-key guard/assignments in wallet and workflows workers. Recursive setTimeout guarantees serial execution by construction, so the manual flags become dead code. --- src/frontend/src/lib/workers/auth.worker.ts | 25 ++++++++-- src/frontend/src/lib/workers/cycles.worker.ts | 47 ++++++++++------- .../src/lib/workers/exchange.worker.ts | 35 +++++++------ .../src/lib/workers/hosting.worker.ts | 37 +++++++------- .../src/lib/workers/icp-cycles-rate.worker.ts | 35 +++++++------ .../src/lib/workers/monitoring.worker.ts | 50 ++++++++++--------- src/frontend/src/lib/workers/wallet.worker.ts | 33 ++++++------ .../src/lib/workers/workflows.worker.ts | 33 ++++++------ 8 files changed, 167 insertions(+), 128 deletions(-) diff --git a/src/frontend/src/lib/workers/auth.worker.ts b/src/frontend/src/lib/workers/auth.worker.ts index 4ca2173f91..925a71d7bd 100644 --- a/src/frontend/src/lib/workers/auth.worker.ts +++ b/src/frontend/src/lib/workers/auth.worker.ts @@ -1,6 +1,7 @@ import { AUTH_TIMER_INTERVAL } from '$lib/constants/app.constants'; import { AuthClientProvider } from '$lib/providers/auth-client.provider'; import type { PostMessageRequest } from '$lib/types/post-message'; +import { nonNullish } from '@dfinity/utils'; import { IdbStorage, KEY_STORAGE_DELEGATION } from '@icp-sdk/auth/client'; import { DelegationChain, isDelegationValid } from '@icp-sdk/core/identity'; @@ -21,18 +22,36 @@ export const onAuthMessage = async ({ let timer: NodeJS.Timeout | undefined = undefined; +// Recursive setTimeout (not setInterval) so the idle check cannot overlap +// itself: the next tick is scheduled only after the previous onIdleSignOut +// resolves. See #2522 / oisy-wallet#9706 for the motivation. +const scheduleNext = (): void => { + timer = setTimeout(async () => { + await onIdleSignOut(); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, AUTH_TIMER_INTERVAL); +}; + /** * The timer is executed only if user has signed in */ -export const startIdleTimer = () => - (timer = setInterval(async () => await onIdleSignOut(), AUTH_TIMER_INTERVAL)); +export const startIdleTimer = () => { + if (nonNullish(timer)) { + return; + } + + scheduleNext(); +}; export const stopIdleTimer = () => { if (!timer) { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; diff --git a/src/frontend/src/lib/workers/cycles.worker.ts b/src/frontend/src/lib/workers/cycles.worker.ts index 463fd1d7df..e9fa4917e1 100644 --- a/src/frontend/src/lib/workers/cycles.worker.ts +++ b/src/frontend/src/lib/workers/cycles.worker.ts @@ -16,7 +16,7 @@ import type { CanisterInfo, CanisterSegment, CanisterSyncData, Segment } from '$ import type { PostMessageDataRequest, PostMessageRequest } from '$lib/types/post-message'; import { emitCanisters, emitSavedCanisters, loadIdentity } from '$lib/utils/worker.utils'; import { CanistersStore } from '$lib/workers/_stores/canisters.store'; -import { isNullish } from '@dfinity/utils'; +import { isNullish, nonNullish } from '@dfinity/utils'; import type { Identity } from '@icp-sdk/core/agent'; import { set } from 'idb-keyval'; @@ -38,7 +38,29 @@ export const onCyclesMessage = async ({ data: dataMsg }: MessageEvent { + timer = setTimeout(async () => { + await syncCanisters({ identity, segments }); + + if (nonNullish(timer)) { + scheduleNext({ identity, segments }); + } + }, SYNC_CYCLES_TIMER_INTERVAL); +}; + const startCyclesTimer = async ({ data: { segments } }: { data: PostMessageDataRequest }) => { + if (nonNullish(timer)) { + return; + } + const identity = await loadIdentity(); if (isNullish(identity)) { @@ -46,12 +68,12 @@ const startCyclesTimer = async ({ data: { segments } }: { data: PostMessageDataR return; } - const sync = async () => await syncCanisters({ identity, segments: segments ?? [] }); + const effectiveSegments = segments ?? []; // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncCanisters({ identity, segments: effectiveSegments }); - timer = setInterval(sync, SYNC_CYCLES_TIMER_INTERVAL); + scheduleNext({ identity, segments: effectiveSegments }); }; const stopCyclesTimer = () => { @@ -59,12 +81,10 @@ const stopCyclesTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; -let syncing = false; - const syncCanisters = async ({ identity, segments @@ -77,23 +97,12 @@ const syncCanisters = async ({ return; } - // We avoid to relaunch a sync while previous sync is not finished - if (syncing) { - return; - } - - syncing = true; - await emitSavedCanisters({ canisterIds: segments.map(({ canisterId }) => canisterId), customStore: cyclesIdbStore }); - try { - await syncIcStatusCanisters({ identity, segments }); - } finally { - syncing = false; - } + await syncIcStatusCanisters({ identity, segments }); }; const syncIcStatusCanisters = async ({ diff --git a/src/frontend/src/lib/workers/exchange.worker.ts b/src/frontend/src/lib/workers/exchange.worker.ts index c0e8abd97a..148740e11b 100644 --- a/src/frontend/src/lib/workers/exchange.worker.ts +++ b/src/frontend/src/lib/workers/exchange.worker.ts @@ -5,7 +5,7 @@ import { exchangeIdbStore } from '$lib/stores/app/idb.store'; import type { CanisterIdText } from '$lib/types/canister'; import type { ExchangePrice } from '$lib/types/exchange'; import type { PostMessageDataResponseExchange, PostMessageRequest } from '$lib/types/post-message'; -import { isNullish } from '@dfinity/utils'; +import { isNullish, nonNullish } from '@dfinity/utils'; import { del, entries, set } from 'idb-keyval'; export const onExchangeMessage = async ({ data: dataMsg }: MessageEvent) => { @@ -22,16 +22,30 @@ export const onExchangeMessage = async ({ data: dataMsg }: MessageEvent { + timer = setTimeout(async () => { + await syncExchange(); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, SYNC_TOKENS_TIMER_INTERVAL); +}; + const startTimer = async () => { - const sync = async () => await syncExchange(); + if (nonNullish(timer)) { + return; + } // First we emit the value we already have in IDB await emitSavedExchanges(); // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncExchange(); - timer = setInterval(sync, SYNC_TOKENS_TIMER_INTERVAL); + scheduleNext(); }; const stopTimer = () => { @@ -39,22 +53,13 @@ const stopTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; -let syncing = false; - let retry = 0; const syncExchange = async () => { - // We avoid to relaunch a sync while previous sync is not finished - if (syncing) { - return; - } - - syncing = true; - try { const icpExchange = await exchangeRateICPToUsd(); @@ -78,8 +83,6 @@ const syncExchange = async () => { } retry++; - } finally { - syncing = false; } }; diff --git a/src/frontend/src/lib/workers/hosting.worker.ts b/src/frontend/src/lib/workers/hosting.worker.ts index 5d555efaf9..df04f0020f 100644 --- a/src/frontend/src/lib/workers/hosting.worker.ts +++ b/src/frontend/src/lib/workers/hosting.worker.ts @@ -2,7 +2,7 @@ import { SYNC_CUSTOM_DOMAIN_TIMER_INTERVAL } from '$lib/constants/app.constants' import { getCustomDomainRegistration } from '$lib/rest/bn.v1.rest'; import type { CustomDomain, CustomDomainName, CustomDomainState } from '$lib/types/custom-domain'; import type { PostMessageDataRequest, PostMessageRequest } from '$lib/types/post-message'; -import { isNullish } from '@dfinity/utils'; +import { isNullish, nonNullish } from '@dfinity/utils'; export const onHostingMessage = async ({ data: dataMsg }: MessageEvent) => { const { msg, data } = dataMsg; @@ -23,34 +23,39 @@ const stopTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; +// Recursive setTimeout (not setInterval) so the registration sync cannot +// overlap itself. See #2522 / oisy-wallet#9706. +const scheduleNext = ({ customDomain }: { customDomain: CustomDomain }): void => { + timer = setTimeout(async () => { + await syncCustomDomainRegistration({ customDomain }); + + if (nonNullish(timer)) { + scheduleNext({ customDomain }); + } + }, SYNC_CUSTOM_DOMAIN_TIMER_INTERVAL); +}; + const startTimer = async ({ data: { customDomain } }: { data: PostMessageDataRequest }) => { + if (nonNullish(timer)) { + return; + } + if (isNullish(customDomain)) { // No custom domain registration to sync return; } - const sync = async () => await syncCustomDomainRegistration({ customDomain }); - // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncCustomDomainRegistration({ customDomain }); - timer = setInterval(sync, SYNC_CUSTOM_DOMAIN_TIMER_INTERVAL); + scheduleNext({ customDomain }); }; -let syncing = false; - const syncCustomDomainRegistration = async ({ customDomain }: { customDomain: CustomDomain }) => { - // We avoid to relaunch a sync while previous sync is not finished - if (syncing) { - return; - } - - syncing = true; - try { const sync = async (): Promise => { const [domainName] = customDomain; @@ -72,8 +77,6 @@ const syncCustomDomainRegistration = async ({ customDomain }: { customDomain: Cu // We sync until Available or Failed stopTimer(); } - - syncing = false; }; const syncCustomDomainRegistrationV1 = async ({ diff --git a/src/frontend/src/lib/workers/icp-cycles-rate.worker.ts b/src/frontend/src/lib/workers/icp-cycles-rate.worker.ts index 25a8d4df2b..193ca3959b 100644 --- a/src/frontend/src/lib/workers/icp-cycles-rate.worker.ts +++ b/src/frontend/src/lib/workers/icp-cycles-rate.worker.ts @@ -5,7 +5,7 @@ import type { PostMessageDataResponseIcpToCyclesRate, PostMessageRequest } from '$lib/types/post-message'; -import { isNullish } from '@dfinity/utils'; +import { isNullish, nonNullish } from '@dfinity/utils'; import { del, get, set } from 'idb-keyval'; export const onIcpToCyclesRateMessage = async ({ @@ -24,35 +24,40 @@ export const onIcpToCyclesRateMessage = async ({ let timer: NodeJS.Timeout | undefined = undefined; +// Recursive setTimeout (not setInterval) so the rate sync cannot overlap +// itself. See #2522 / oisy-wallet#9706. +const scheduleNext = (): void => { + timer = setTimeout(async () => { + await syncRate(); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, SYNC_TOKENS_TIMER_INTERVAL); +}; + const startTimer = async () => { - const sync = async () => await syncRate(); + if (nonNullish(timer)) { + return; + } // First we emit the value we already have in IDB await emitSavedRate(); // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncRate(); - timer = setInterval(sync, SYNC_TOKENS_TIMER_INTERVAL); + scheduleNext(); }; const stopTimer = () => { - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; -let syncing = false; - let retry = 0; const syncRate = async () => { - // We avoid to relaunch a sync while previous sync is not finished - if (syncing) { - return; - } - - syncing = true; - try { const trillionRatio = await getIcpToCyclesConversionRate(); @@ -71,8 +76,6 @@ const syncRate = async () => { } retry++; - } finally { - syncing = false; } }; diff --git a/src/frontend/src/lib/workers/monitoring.worker.ts b/src/frontend/src/lib/workers/monitoring.worker.ts index 63870fe5f1..2a4c3e05ce 100644 --- a/src/frontend/src/lib/workers/monitoring.worker.ts +++ b/src/frontend/src/lib/workers/monitoring.worker.ts @@ -56,6 +56,10 @@ const startMonitoringTimer = async ({ }: { data: PostMessageDataRequest; }) => { + if (nonNullish(timer)) { + return; + } + const identity = await loadIdentity(); if (isNullish(identity)) { @@ -69,18 +73,29 @@ const startMonitoringTimer = async ({ return; } - const sync = async () => - await syncMonitoring({ - identity, - segments: segments ?? [], - missionControlId, - withMonitoringHistory: withMonitoringHistory ?? false - }); + const params = { + identity, + segments: segments ?? [], + missionControlId, + withMonitoringHistory: withMonitoringHistory ?? false + }; + + // Recursive setTimeout (not setInterval) so the monitoring sync cannot + // overlap itself. See #2522 / oisy-wallet#9706. + const scheduleNext = (): void => { + timer = setTimeout(async () => { + await syncMonitoring(params); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, SYNC_MONITORING_TIMER_INTERVAL); + }; // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncMonitoring(params); - timer = setInterval(sync, SYNC_MONITORING_TIMER_INTERVAL); + scheduleNext(); }; const stopMonitoringTimer = () => { @@ -88,12 +103,10 @@ const stopMonitoringTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; -let syncing = false; - const syncMonitoring = async ({ identity, segments, @@ -108,23 +121,12 @@ const syncMonitoring = async ({ return; } - // We avoid to relaunch a sync while previous sync is not finished - if (syncing) { - return; - } - - syncing = true; - await emitSavedCanisters({ canisterIds: segments.map(({ canisterId }) => canisterId), customStore: monitoringIdbStore }); - try { - await syncMonitoringForSegments({ identity, segments, ...rest }); - } finally { - syncing = false; - } + await syncMonitoringForSegments({ identity, segments, ...rest }); }; // eslint-disable-next-line local-rules/prefer-object-params diff --git a/src/frontend/src/lib/workers/wallet.worker.ts b/src/frontend/src/lib/workers/wallet.worker.ts index b4474628bb..46f2f94afc 100644 --- a/src/frontend/src/lib/workers/wallet.worker.ts +++ b/src/frontend/src/lib/workers/wallet.worker.ts @@ -23,7 +23,7 @@ import type { import { loadIdentity } from '$lib/utils/worker.utils'; import { requestTransactions } from '$lib/workers/_services/wallet-worker.services'; import { WalletStore, type IndexedTransactions } from '$lib/workers/_stores/wallet-worker.store'; -import { isNullish, jsonReplacer } from '@dfinity/utils'; +import { isNullish, jsonReplacer, nonNullish } from '@dfinity/utils'; import { decodeIcrcAccount, type IcrcAccount } from '@icp-sdk/canisters/ledger/icrc'; import type { Identity } from '@icp-sdk/core/agent'; import { Principal } from '@icp-sdk/core/principal'; @@ -51,7 +51,7 @@ const stopTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; @@ -108,26 +108,29 @@ const startTimerWithAccount = async ({ emitSavedWallet({ store }); - const sync = async () => await syncWallet({ identity, store }); + // Recursive setTimeout (not setInterval) so each account's sync cannot + // overlap itself. See #2522 / oisy-wallet#9706. Each account closes + // over its own scheduleNext so multiple accounts still tick in + // parallel as before. + const scheduleNext = (): void => { + timer = setTimeout(async () => { + await syncWallet({ identity, store }); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, SYNC_WALLET_TIMER_INTERVAL); + }; // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncWallet({ identity, store }); - timer = setInterval(sync, SYNC_WALLET_TIMER_INTERVAL); + scheduleNext(); }; -const syncing: Record = {}; - let initialized = false; const syncWallet = async ({ identity, store }: { identity: Identity; store: WalletStore }) => { - // We avoid to relaunch a sync while previous sync is not finished - if (syncing[store.idbKey] === true) { - return; - } - - syncing[store.idbKey] = true; - const request = ({ identity: _, certified @@ -163,8 +166,6 @@ const syncWallet = async ({ identity, store }: { identity: Identity; store: Wall }); await store.save(); - - syncing[store.idbKey] = false; }; const postMessageWallet = ({ diff --git a/src/frontend/src/lib/workers/workflows.worker.ts b/src/frontend/src/lib/workers/workflows.worker.ts index b09f3a3927..447178b3be 100644 --- a/src/frontend/src/lib/workers/workflows.worker.ts +++ b/src/frontend/src/lib/workers/workflows.worker.ts @@ -17,8 +17,8 @@ import { requestWorkflows, type RequestWorkflowsResponse } from '$lib/workers/_services/workflows-worker.services'; -import { type WorkflowsIdbKey, WorkflowsStore } from '$lib/workers/_stores/workflows-worker.store'; -import { isEmptyString, isNullish, jsonReplacer } from '@dfinity/utils'; +import { WorkflowsStore } from '$lib/workers/_stores/workflows-worker.store'; +import { isEmptyString, isNullish, jsonReplacer, nonNullish } from '@dfinity/utils'; import type { Identity } from '@icp-sdk/core/agent'; import { Principal } from '@icp-sdk/core/principal'; @@ -45,7 +45,7 @@ const stopTimer = () => { return; } - clearInterval(timer); + clearTimeout(timer); timer = undefined; }; @@ -81,16 +81,24 @@ const startTimerForSatellite = async ({ emitSavedWorkflows({ store }); - const sync = async () => await syncWorkflows({ identity, store }); + // Recursive setTimeout (not setInterval) so the workflow sync cannot + // overlap itself. See #2522 / oisy-wallet#9706. + const scheduleNext = (): void => { + timer = setTimeout(async () => { + await syncWorkflows({ identity, store }); + + if (nonNullish(timer)) { + scheduleNext(); + } + }, SYNC_WORKFLOWS_TIMER_INTERVAL); + }; // We sync the cycles now but also schedule the update afterwards - await sync(); + await syncWorkflows({ identity, store }); - timer = setInterval(sync, SYNC_WORKFLOWS_TIMER_INTERVAL); + scheduleNext(); }; -const syncing: Record = {}; - let initialized = false; const syncWorkflows = async ({ @@ -100,13 +108,6 @@ const syncWorkflows = async ({ identity: Identity; store: WorkflowsStore; }) => { - // We avoid to relaunch a sync while previous sync is not finished - if (syncing[store.idbKey] === true) { - return; - } - - syncing[store.idbKey] = true; - const request = ({ identity: _, certified @@ -141,8 +142,6 @@ const syncWorkflows = async ({ }); await store.save(); - - syncing[store.idbKey] = false; }; const postMessageWorkflows = ({