Skip to content

Commit 82c5ba1

Browse files
committed
scripts(claude): migrate fs calls to async with fire-and-forget loads
Constructor pattern for CostTracker and ProgressTracker relied on sync readFileSync to populate state before returning. Replaced with fire-and-forget async loads: the constructor initializes a fresh- default state synchronously, kicks off fs.readFile(), and swaps in the loaded value when it resolves. Known race window documented inline: if track() / recordPhase() fires before the load resolves, it mutates the default and the first save clobbers prior persisted state. Acceptable here — the consequences are losing fractions of a cent of cost tracking or a one-phase ETA blip, in a single-user dev-machine script. If ever load-bearing, promote to read-modify-write with a mutex. Saves moved to fire-and-forget async too (they already ignored errors via try/catch, so the best-effort semantic is unchanged).
1 parent d95cc58 commit 82c5ba1

1 file changed

Lines changed: 82 additions & 49 deletions

File tree

scripts/claude.mts

Lines changed: 82 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@
55
*/
66

77
import crypto from 'node:crypto'
8-
import {
9-
existsSync,
10-
readFileSync,
11-
writeFileSync,
12-
promises as fs,
13-
} from 'node:fs'
8+
import { existsSync, promises as fs } from 'node:fs'
149
import os from 'node:os'
1510
import path from 'node:path'
1611
import process from 'node:process'
@@ -325,37 +320,59 @@ class CostTracker {
325320
constructor(model: string = 'claude-sonnet-4-5') {
326321
this.model = model
327322
this.session = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }
328-
this.monthly = this.loadMonthlyStats()
329-
this.startTime = Date.now()
330-
}
331-
332-
loadMonthlyStats(): MonthlyStats {
333-
try {
334-
if (existsSync(STORAGE_PATHS.stats)) {
335-
const data = JSON.parse(readFileSync(STORAGE_PATHS.stats, 'utf8'))
336-
// YYYY-MM
337-
const currentMonth = new Date().toISOString().slice(0, 7)
338-
if (data.month === currentMonth) {
339-
return data
340-
}
341-
}
342-
} catch {
343-
// Ignore errors, start fresh.
344-
}
345-
return {
323+
// Start with a fresh-month default. The on-disk monthly stats (if
324+
// any) load asynchronously below and replace this.monthly when
325+
// they resolve. Known race: if track() fires before the load
326+
// resolves, it mutates the default baseline and the first save
327+
// clobbers the prior month's accumulated value. Acceptable here
328+
// because (a) the Claude CLI session is single-user and typically
329+
// has a human-paced delay between start and first tracked event,
330+
// and (b) the persisted quantity is per-session cost accounting —
331+
// losing at most one update window costs fractions of a cent. If
332+
// the race ever becomes load-bearing, promote this to a proper
333+
// read-modify-write on save() with a mutex.
334+
this.monthly = {
346335
month: new Date().toISOString().slice(0, 7),
347336
cost: 0,
348337
fixes: 0,
349338
sessions: 0,
350339
}
340+
this.startTime = Date.now()
341+
void this.loadMonthlyStats().then(loaded => {
342+
if (loaded) {
343+
this.monthly = loaded
344+
}
345+
})
351346
}
352347

353-
saveMonthlyStats(): void {
348+
async loadMonthlyStats(): Promise<MonthlyStats | undefined> {
349+
if (!existsSync(STORAGE_PATHS.stats)) {
350+
return undefined
351+
}
354352
try {
355-
writeFileSync(STORAGE_PATHS.stats, JSON.stringify(this.monthly, null, 2))
353+
const text = await fs.readFile(STORAGE_PATHS.stats, 'utf8')
354+
const data = JSON.parse(text) as MonthlyStats
355+
// YYYY-MM — only honor disk state if the persisted month matches
356+
// the current one; otherwise the caller keeps the fresh default.
357+
const currentMonth = new Date().toISOString().slice(0, 7)
358+
if (data.month === currentMonth) {
359+
return data
360+
}
356361
} catch {
357-
// Ignore errors.
362+
// Unreadable, invalid JSON, etc. — keep the fresh default.
358363
}
364+
return undefined
365+
}
366+
367+
saveMonthlyStats(): void {
368+
// Fire-and-forget async write. Best-effort persistence — existing
369+
// try/catch pattern already swallowed failures, so the semantic
370+
// doesn't change. Keeps the calling track() path synchronous
371+
// without pinning fs to a sync primitive.
372+
fs.writeFile(
373+
STORAGE_PATHS.stats,
374+
JSON.stringify(this.monthly, null, 2),
375+
).catch(() => {})
359376
}
360377

361378
track(usage: UsageData): void {
@@ -437,38 +454,54 @@ class ProgressTracker {
437454
this.phases = []
438455
this.currentPhase = null
439456
this.startTime = Date.now()
440-
this.history = this.loadHistory()
457+
// Empty history until the on-disk state loads. Same race as in
458+
// CostTracker: a brief window where ETA estimation sees no prior
459+
// sessions. In practice history reads faster than the first phase
460+
// starts, and worst case we emit a "no ETA yet" message for one
461+
// phase.
462+
this.history = []
463+
void this.loadHistory().then(loaded => {
464+
if (loaded) {
465+
this.history = loaded
466+
}
467+
})
441468
}
442469

443-
loadHistory(): Array<{ phases: PhaseRecord[]; timestamp: number }> {
470+
async loadHistory(): Promise<
471+
Array<{ phases: PhaseRecord[]; timestamp: number }> | undefined
472+
> {
473+
if (!existsSync(STORAGE_PATHS.history)) {
474+
return undefined
475+
}
444476
try {
445-
if (existsSync(STORAGE_PATHS.history)) {
446-
const data = JSON.parse(readFileSync(STORAGE_PATHS.history, 'utf8'))
447-
// Keep only last 50 sessions.
448-
return data.sessions.slice(-50)
477+
const text = await fs.readFile(STORAGE_PATHS.history, 'utf8')
478+
const data = JSON.parse(text) as {
479+
sessions: Array<{ phases: PhaseRecord[]; timestamp: number }>
449480
}
481+
// Keep only the last 50 sessions — old ones aren't useful for ETA.
482+
return data.sessions.slice(-50)
450483
} catch {
451-
// Ignore errors.
484+
// Unreadable / invalid JSON — keep empty history.
452485
}
453-
return []
486+
return undefined
454487
}
455488

456489
saveHistory(): void {
457-
try {
458-
const data = {
459-
sessions: [
460-
...this.history,
461-
{ phases: this.phases, timestamp: Date.now() },
462-
],
463-
}
464-
// Keep only last 50 sessions.
465-
if (data.sessions.length > 50) {
466-
data.sessions = data.sessions.slice(-50)
467-
}
468-
writeFileSync(STORAGE_PATHS.history, JSON.stringify(data, null, 2))
469-
} catch {
470-
// Ignore errors.
490+
// Fire-and-forget async write. Existing try/catch already ignored
491+
// failures, so the best-effort semantic is unchanged.
492+
const data = {
493+
sessions: [
494+
...this.history,
495+
{ phases: this.phases, timestamp: Date.now() },
496+
],
497+
}
498+
// Keep only last 50 sessions.
499+
if (data.sessions.length > 50) {
500+
data.sessions = data.sessions.slice(-50)
471501
}
502+
fs.writeFile(STORAGE_PATHS.history, JSON.stringify(data, null, 2)).catch(
503+
() => {},
504+
)
472505
}
473506

474507
startPhase(name: string): void {

0 commit comments

Comments
 (0)