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 type { Account } from '../../db/schema.js';
import type { AgentInfo } from '../types.js';
@@ -57,11 +58,11 @@ export interface AccountHealthResult {
activeAgentCount: number;
}
function readCredentials(configDir: string): OAuthCredentials | null {
async function readCredentials(configDir: string): Promise<OAuthCredentials | null> {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = readFileSync(credPath, 'utf-8');
const raw = await readFile(credPath, 'utf-8');
const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth;
if (!oauth || !oauth.accessToken) return null;
@@ -86,19 +87,19 @@ function isTokenExpired(credentials: OAuthCredentials): boolean {
* Write credentials back to the config directory.
* Matches ccswitch's update_credentials_with_token() behavior.
*/
function writeCredentials(
async function writeCredentials(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): void {
): Promise<void> {
const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {};
try {
if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
existing = JSON.parse(await readFile(credPath, 'utf-8'));
}
} catch {
// Start fresh if can't read
@@ -116,10 +117,10 @@ function writeCredentials(
existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true });
await mkdir(dirname(credPath), { recursive: true });
// 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');
}
@@ -249,7 +250,7 @@ export async function checkAccountHealth(
subscriptionType = result.credentials.subscriptionType;
} else {
// Legacy path: direct function calls
const credentials = readCredentials(configDir);
const credentials = await readCredentials(configDir);
if (!credentials) {
return {
...base,
@@ -284,7 +285,7 @@ export async function checkAccountHealth(
// Persist the refreshed credentials back to disk
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);
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.
*/
export async function ensureAccountCredentials(configDir: string): Promise<boolean> {
const credentials = readCredentials(configDir);
const credentials = await readCredentials(configDir);
if (!credentials) {
log.warn({ configDir }, 'no credentials found');
return false;
@@ -368,7 +369,7 @@ export async function ensureAccountCredentials(configDir: string): Promise<boole
}
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');
return true;
}

View File

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

View File

@@ -53,7 +53,7 @@ export interface AccountCredentialManager {
* Read credentials from a config directory.
* 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.
@@ -76,7 +76,7 @@ export interface AccountCredentialManager {
accessToken: string,
refreshToken: string,
expiresIn: number,
): void;
): Promise<void>;
/**
* Ensure credentials are valid, refreshing if needed.