From 5fa5ecbbca6526081efbfabd672d8126eb3de293 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 20 Feb 2026 10:57:24 -0500 Subject: [PATCH 1/6] Extract shared runtime utilities for Canton scripts. Add opinionated core utilities for continuous loops, sync state persistence, and SHA-256 hashing so downstream repos can remove duplicated script infrastructure. Co-authored-by: Cursor --- src/core/utils/continuous-loop.ts | 77 ++++++++++++++ src/core/utils/hash.ts | 8 ++ src/core/utils/index.ts | 3 + src/core/utils/sync-state-tracker.ts | 145 +++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/core/utils/continuous-loop.ts create mode 100644 src/core/utils/hash.ts create mode 100644 src/core/utils/sync-state-tracker.ts diff --git a/src/core/utils/continuous-loop.ts b/src/core/utils/continuous-loop.ts new file mode 100644 index 00000000..e02d8f03 --- /dev/null +++ b/src/core/utils/continuous-loop.ts @@ -0,0 +1,77 @@ +export interface ContinuousLoopOptions { + /** Interval between cycles in seconds */ + readonly intervalSeconds: number; + /** + * Callback to run on each cycle. + * + * @param isShutdownRequested - Function to check if shutdown has been requested during the cycle + * @returns Promise that resolves when the cycle is complete (return value is ignored) + */ + readonly runCycle: (isShutdownRequested: () => boolean) => Promise; + /** Optional callback for cleanup on shutdown */ + readonly onShutdown?: () => Promise; + /** Optional callback for per-cycle errors */ + readonly onCycleError?: (error: unknown) => void; +} + +/** + * Run a function in a continuous loop with graceful shutdown support. + */ +export async function runContinuousLoop(options: ContinuousLoopOptions): Promise { + const { intervalSeconds, runCycle, onShutdown, onCycleError } = options; + + let shutdownRequested = false; + let sleepResolve: (() => void) | null = null; + let sleepTimeout: NodeJS.Timeout | null = null; + + const handleShutdown = (_signal: string): void => { + shutdownRequested = true; + if (sleepTimeout) { + clearTimeout(sleepTimeout); + sleepTimeout = null; + } + if (sleepResolve) { + sleepResolve(); + sleepResolve = null; + } + }; + + const sigintHandler = (): void => handleShutdown('SIGINT'); + const sigtermHandler = (): void => handleShutdown('SIGTERM'); + + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + + const isShutdownRequested = (): boolean => shutdownRequested; + + try { + for (;;) { + if (isShutdownRequested()) { + break; + } + try { + await runCycle(isShutdownRequested); + } catch (error) { + onCycleError?.(error); + } + + if (isShutdownRequested()) { + break; + } + await new Promise((resolve) => { + sleepResolve = resolve; + sleepTimeout = setTimeout(() => { + sleepTimeout = null; + sleepResolve = null; + resolve(); + }, intervalSeconds * 1000); + }); + } + } finally { + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigtermHandler); + if (typeof onShutdown === 'function') { + await onShutdown(); + } + } +} diff --git a/src/core/utils/hash.ts b/src/core/utils/hash.ts new file mode 100644 index 00000000..42b494b6 --- /dev/null +++ b/src/core/utils/hash.ts @@ -0,0 +1,8 @@ +import { createHash } from 'crypto'; + +/** + * Creates a SHA-256 hash for a string value. + */ +export function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index cc3ffc2d..ebcd731e 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1,2 +1,5 @@ export { waitForCondition, type WaitForConditionOptions } from './polling'; export { extractString, hasStringProperty, isNonEmptyString, isNumber, isRecord, isString } from './type-guards'; +export { runContinuousLoop, type ContinuousLoopOptions } from './continuous-loop'; +export { SyncStateTracker, type SyncState, type SyncStateTrackerOptions } from './sync-state-tracker'; +export { hashValue } from './hash'; diff --git a/src/core/utils/sync-state-tracker.ts b/src/core/utils/sync-state-tracker.ts new file mode 100644 index 00000000..367da622 --- /dev/null +++ b/src/core/utils/sync-state-tracker.ts @@ -0,0 +1,145 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface SyncState { + /** The last offset we successfully processed */ + readonly lastSuccessfulOffset: number; + /** The ledger end at the time of our last successful sync */ + readonly ledgerEndAtSync: number; + /** ISO timestamp of last update */ + readonly lastUpdated: string; + /** Additional metadata for debugging */ + readonly metadata?: { + readonly hostname?: string; + readonly processId?: number; + }; +} + +export interface SyncStateTrackerOptions { + /** Network name (mainnet, devnet) */ + readonly network: string; + /** Provider name (intellect, 5n) */ + readonly provider: string; + /** Optional: Custom directory for state files (defaults to ~/.canton-sync-state) */ + readonly stateDirectory?: string; +} + +function getDefaultStateDirectory(): string { + const envDir = process.env['CANTON_SYNC_STATE_DIR']; + if (envDir) { + return envDir; + } + + const homeDir = os.homedir(); + return path.join(homeDir, '.canton-sync-state'); +} + +/** + * Local file-based tracking of ledger sync progress. + */ +export class SyncStateTracker { + private readonly stateFilePath: string; + private readonly directoryWritable: boolean; + private cachedState: SyncState | null = null; + + constructor(options: SyncStateTrackerOptions) { + const stateDir = options.stateDirectory ?? getDefaultStateDirectory(); + + let isWritable = true; + if (!fs.existsSync(stateDir)) { + try { + fs.mkdirSync(stateDir, { recursive: true }); + } catch { + isWritable = false; + } + } + + this.directoryWritable = isWritable; + this.stateFilePath = path.join(stateDir, `${options.network}-${options.provider}.json`); + } + + isWritable(): boolean { + return this.directoryWritable; + } + + getState(): SyncState | null { + if (this.cachedState) { + return this.cachedState; + } + + if (!this.directoryWritable) { + return null; + } + + try { + if (!fs.existsSync(this.stateFilePath)) { + return null; + } + + const content = fs.readFileSync(this.stateFilePath, 'utf-8'); + const state = JSON.parse(content) as SyncState; + + if ( + typeof state.lastSuccessfulOffset !== 'number' || + typeof state.ledgerEndAtSync !== 'number' || + typeof state.lastUpdated !== 'string' + ) { + return null; + } + + this.cachedState = state; + return state; + } catch { + return null; + } + } + + updateState(offset: number, ledgerEnd: number): void { + if (!this.directoryWritable) { + return; + } + + const state: SyncState = { + lastSuccessfulOffset: offset, + ledgerEndAtSync: ledgerEnd, + lastUpdated: new Date().toISOString(), + metadata: { + hostname: process.env['HOSTNAME'] ?? 'unknown', + processId: process.pid, + }, + }; + + try { + const tempPath = `${this.stateFilePath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(state, null, 2)); + fs.renameSync(tempPath, this.stateFilePath); + this.cachedState = state; + } catch { + // Ignore local persistence errors and continue with in-memory progress. + } + } + + getStartingOffset(currentLedgerEnd: number): { offset: number; wasReset: boolean } | null { + const state = this.getState(); + + if (!state) { + return null; + } + + if (state.lastSuccessfulOffset > currentLedgerEnd) { + return { offset: 0, wasReset: true }; + } + + const safeOffset = Math.max(0, state.lastSuccessfulOffset - 1); + return { offset: safeOffset, wasReset: false }; + } + + resetState(): void { + this.cachedState = null; + } + + getStateFilePath(): string { + return this.stateFilePath; + } +} From 632460c72358a943c8125a7bcec8e3cb4033c407 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 16:28:46 +0000 Subject: [PATCH 2/6] fix: delete persisted state file on disk in resetState() resetState() only cleared the in-memory cache but left the state file on disk. Subsequent getState() calls would re-read the stale file, making resetState() effectively a no-op. Now unlinkSync the file too. --- src/core/utils/sync-state-tracker.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/utils/sync-state-tracker.ts b/src/core/utils/sync-state-tracker.ts index 367da622..12af50a9 100644 --- a/src/core/utils/sync-state-tracker.ts +++ b/src/core/utils/sync-state-tracker.ts @@ -137,6 +137,13 @@ export class SyncStateTracker { resetState(): void { this.cachedState = null; + try { + if (fs.existsSync(this.stateFilePath)) { + fs.unlinkSync(this.stateFilePath); + } + } catch { + // Best-effort deletion; in-memory cache is already cleared. + } } getStateFilePath(): string { From f333421c7dcbef91928e23e9ad5346041ad7701a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 16:38:38 +0000 Subject: [PATCH 3/6] fix: update in-memory cache before persistence in SyncStateTracker.updateState Move this.cachedState assignment before the try block so in-memory progress is preserved when filesystem writes fail, matching the stated intent of the catch block comment. --- src/core/utils/sync-state-tracker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/utils/sync-state-tracker.ts b/src/core/utils/sync-state-tracker.ts index 12af50a9..ca0f8504 100644 --- a/src/core/utils/sync-state-tracker.ts +++ b/src/core/utils/sync-state-tracker.ts @@ -110,11 +110,12 @@ export class SyncStateTracker { }, }; + this.cachedState = state; + try { const tempPath = `${this.stateFilePath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(state, null, 2)); fs.renameSync(tempPath, this.stateFilePath); - this.cachedState = state; } catch { // Ignore local persistence errors and continue with in-memory progress. } From 5ff1182352139dff645b0585ba47673827d903fb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 20 Feb 2026 11:47:00 -0500 Subject: [PATCH 4/6] Keep hash utility in canton repo. Remove hash export from node-sdk core utils so hashing remains a local canton concern for now. Co-authored-by: Cursor --- src/core/utils/hash.ts | 8 -------- src/core/utils/index.ts | 1 - 2 files changed, 9 deletions(-) delete mode 100644 src/core/utils/hash.ts diff --git a/src/core/utils/hash.ts b/src/core/utils/hash.ts deleted file mode 100644 index 42b494b6..00000000 --- a/src/core/utils/hash.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createHash } from 'crypto'; - -/** - * Creates a SHA-256 hash for a string value. - */ -export function hashValue(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index ebcd731e..8f483740 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -2,4 +2,3 @@ export { waitForCondition, type WaitForConditionOptions } from './polling'; export { extractString, hasStringProperty, isNonEmptyString, isNumber, isRecord, isString } from './type-guards'; export { runContinuousLoop, type ContinuousLoopOptions } from './continuous-loop'; export { SyncStateTracker, type SyncState, type SyncStateTrackerOptions } from './sync-state-tracker'; -export { hashValue } from './hash'; From ae6f3681adc108ca466f9148c78d489fa431bab2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 16:49:57 +0000 Subject: [PATCH 5/6] fix: persist reset state when ledger reset detected in getStartingOffset Clear cached state and state file via resetState() when lastSuccessfulOffset > currentLedgerEnd, so the reset detection is durable even if the caller crashes before calling updateState(). --- src/core/utils/sync-state-tracker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/utils/sync-state-tracker.ts b/src/core/utils/sync-state-tracker.ts index ca0f8504..29a30480 100644 --- a/src/core/utils/sync-state-tracker.ts +++ b/src/core/utils/sync-state-tracker.ts @@ -129,6 +129,7 @@ export class SyncStateTracker { } if (state.lastSuccessfulOffset > currentLedgerEnd) { + this.resetState(); return { offset: 0, wasReset: true }; } From c3362b859a87b36c68c1418ec958a826b0be9898 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 17:01:11 +0000 Subject: [PATCH 6/6] fix: wrap onCycleError callback in try-catch to prevent loop termination If the user-provided onCycleError handler throws, the exception would propagate out of the catch block and terminate the continuous loop. Wrapping it in its own try-catch ensures the loop remains resilient. --- src/core/utils/continuous-loop.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/utils/continuous-loop.ts b/src/core/utils/continuous-loop.ts index e02d8f03..61c80c09 100644 --- a/src/core/utils/continuous-loop.ts +++ b/src/core/utils/continuous-loop.ts @@ -52,7 +52,11 @@ export async function runContinuousLoop(options: ContinuousLoopOptions): Promise try { await runCycle(isShutdownRequested); } catch (error) { - onCycleError?.(error); + try { + onCycleError?.(error); + } catch { + // Prevent a failing error callback from terminating the loop + } } if (isShutdownRequested()) {