fix: Convert sync file I/O to async in credential manager and usage module

Replace readFileSync/writeFileSync/mkdirSync with async equivalents from
fs/promises in default-credential-manager.ts and usage.ts to stop blocking
the Node.js event loop during credential read/write operations.
This commit is contained in:
Lukas May
2026-03-04 12:26:37 +01:00
parent 73a4c6cb0c
commit a0152ce238
3 changed files with 28 additions and 26 deletions

View File

@@ -1,4 +1,5 @@
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { Account } from '../../db/schema.js'; import type { Account } from '../../db/schema.js';
import type { AgentInfo } from '../types.js'; import type { AgentInfo } from '../types.js';
@@ -57,11 +58,11 @@ export interface AccountHealthResult {
activeAgentCount: number; activeAgentCount: number;
} }
function readCredentials(configDir: string): OAuthCredentials | null { async function readCredentials(configDir: string): Promise<OAuthCredentials | null> {
try { try {
const credPath = join(configDir, '.credentials.json'); const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null; if (!existsSync(credPath)) return null;
const raw = readFileSync(credPath, 'utf-8'); const raw = await readFile(credPath, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth; const oauth = parsed.claudeAiOauth;
if (!oauth || !oauth.accessToken) return null; if (!oauth || !oauth.accessToken) return null;
@@ -86,19 +87,19 @@ function isTokenExpired(credentials: OAuthCredentials): boolean {
* Write credentials back to the config directory. * Write credentials back to the config directory.
* Matches ccswitch's update_credentials_with_token() behavior. * Matches ccswitch's update_credentials_with_token() behavior.
*/ */
function writeCredentials( async function writeCredentials(
configDir: string, configDir: string,
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
expiresIn: number, expiresIn: number,
): void { ): Promise<void> {
const credPath = join(configDir, '.credentials.json'); const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields // Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
try { try {
if (existsSync(credPath)) { if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(credPath, 'utf-8')); existing = JSON.parse(await readFile(credPath, 'utf-8'));
} }
} catch { } catch {
// Start fresh if can't read // Start fresh if can't read
@@ -116,10 +117,10 @@ function writeCredentials(
existing.claudeAiOauth = claudeAiOauth; existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists // Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true }); await mkdir(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency with ccswitch) // Write back (compact JSON for consistency with ccswitch)
writeFileSync(credPath, JSON.stringify(existing)); await writeFile(credPath, JSON.stringify(existing));
log.debug({ configDir }, 'credentials written after token refresh'); log.debug({ configDir }, 'credentials written after token refresh');
} }
@@ -249,7 +250,7 @@ export async function checkAccountHealth(
subscriptionType = result.credentials.subscriptionType; subscriptionType = result.credentials.subscriptionType;
} else { } else {
// Legacy path: direct function calls // Legacy path: direct function calls
const credentials = readCredentials(configDir); const credentials = await readCredentials(configDir);
if (!credentials) { if (!credentials) {
return { return {
...base, ...base,
@@ -284,7 +285,7 @@ export async function checkAccountHealth(
// Persist the refreshed credentials back to disk // Persist the refreshed credentials back to disk
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken; const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
writeCredentials(configDir, accessToken, newRefreshToken, refreshed.expiresIn); await writeCredentials(configDir, accessToken, newRefreshToken, refreshed.expiresIn);
currentExpiresAt = Date.now() + (refreshed.expiresIn * 1000); currentExpiresAt = Date.now() + (refreshed.expiresIn * 1000);
log.info({ accountId: account.id, expiresIn: refreshed.expiresIn }, 'token refreshed and persisted'); log.info({ accountId: account.id, expiresIn: refreshed.expiresIn }, 'token refreshed and persisted');
} }
@@ -344,7 +345,7 @@ export async function checkAccountHealth(
* @deprecated Use AccountCredentialManager.ensureValid() instead for event emission support. * @deprecated Use AccountCredentialManager.ensureValid() instead for event emission support.
*/ */
export async function ensureAccountCredentials(configDir: string): Promise<boolean> { export async function ensureAccountCredentials(configDir: string): Promise<boolean> {
const credentials = readCredentials(configDir); const credentials = await readCredentials(configDir);
if (!credentials) { if (!credentials) {
log.warn({ configDir }, 'no credentials found'); log.warn({ configDir }, 'no credentials found');
return false; return false;
@@ -368,7 +369,7 @@ export async function ensureAccountCredentials(configDir: string): Promise<boole
} }
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken; const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
writeCredentials(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn); await writeCredentials(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
log.info({ configDir, expiresIn: refreshed.expiresIn }, 'credentials refreshed before spawn'); log.info({ configDir, expiresIn: refreshed.expiresIn }, 'credentials refreshed before spawn');
return true; return true;
} }

View File

@@ -6,7 +6,8 @@
* and emits events on credential state changes. * and emits events on credential state changes.
*/ */
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { EventBus } from '../../events/index.js'; import type { EventBus } from '../../events/index.js';
import type { import type {
@@ -45,12 +46,12 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
/** /**
* Read credentials from a config directory. * Read credentials from a config directory.
*/ */
read(configDir: string): OAuthCredentials | null { async read(configDir: string): Promise<OAuthCredentials | null> {
try { try {
const credPath = join(configDir, '.credentials.json'); const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null; if (!existsSync(credPath)) return null;
const raw = readFileSync(credPath, 'utf-8'); const raw = await readFile(credPath, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth; const oauth = parsed.claudeAiOauth;
@@ -112,19 +113,19 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
/** /**
* Write updated credentials to the config directory. * Write updated credentials to the config directory.
*/ */
write( async write(
configDir: string, configDir: string,
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
expiresIn: number, expiresIn: number,
): void { ): Promise<void> {
const credPath = join(configDir, '.credentials.json'); const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields // Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
try { try {
if (existsSync(credPath)) { if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(credPath, 'utf-8')); existing = JSON.parse(await readFile(credPath, 'utf-8'));
} }
} catch { } catch {
// Start fresh if can't read // Start fresh if can't read
@@ -142,10 +143,10 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
existing.claudeAiOauth = claudeAiOauth; existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists // Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true }); await mkdir(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency) // Write back (compact JSON for consistency)
writeFileSync(credPath, JSON.stringify(existing)); await writeFile(credPath, JSON.stringify(existing));
log.debug({ configDir }, 'credentials written after token refresh'); log.debug({ configDir }, 'credentials written after token refresh');
} }
@@ -153,7 +154,7 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
* Ensure credentials are valid, refreshing if needed. * Ensure credentials are valid, refreshing if needed.
*/ */
async ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult> { async ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
const credentials = this.read(configDir); const credentials = await this.read(configDir);
if (!credentials) { if (!credentials) {
log.warn({ configDir, accountId }, 'no credentials found'); log.warn({ configDir, accountId }, 'no credentials found');
@@ -206,7 +207,7 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
// Write refreshed credentials // Write refreshed credentials
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken; const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
this.write(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn); await this.write(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
const newExpiresAt = Date.now() + refreshed.expiresIn * 1000; const newExpiresAt = Date.now() + refreshed.expiresIn * 1000;
log.info({ configDir, accountId, expiresIn: refreshed.expiresIn }, 'credentials refreshed'); log.info({ configDir, accountId, expiresIn: refreshed.expiresIn }, 'credentials refreshed');
@@ -215,7 +216,7 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
this.emitValidated(accountId, true, newExpiresAt, true); this.emitValidated(accountId, true, newExpiresAt, true);
// Read back updated credentials // Read back updated credentials
const updatedCredentials = this.read(configDir); const updatedCredentials = await this.read(configDir);
return { return {
valid: true, valid: true,
credentials: updatedCredentials, credentials: updatedCredentials,
@@ -228,7 +229,7 @@ export class DefaultAccountCredentialManager implements AccountCredentialManager
* Validate credentials without attempting refresh. * Validate credentials without attempting refresh.
*/ */
async validate(configDir: string, accountId?: string): Promise<CredentialValidationResult> { async validate(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
const credentials = this.read(configDir); const credentials = await this.read(configDir);
if (!credentials) { if (!credentials) {
this.emitValidated(accountId, false, null, false); this.emitValidated(accountId, false, null, false);

View File

@@ -53,7 +53,7 @@ export interface AccountCredentialManager {
* Read credentials from a config directory. * Read credentials from a config directory.
* Returns null if credentials file is missing or malformed. * Returns null if credentials file is missing or malformed.
*/ */
read(configDir: string): OAuthCredentials | null; read(configDir: string): Promise<OAuthCredentials | null>;
/** /**
* Check if credentials are expired or about to expire. * Check if credentials are expired or about to expire.
@@ -76,7 +76,7 @@ export interface AccountCredentialManager {
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
expiresIn: number, expiresIn: number,
): void; ): Promise<void>;
/** /**
* Ensure credentials are valid, refreshing if needed. * Ensure credentials are valid, refreshing if needed.