fix: Convert sync file I/O to async in credential handler and account setup

Removes blocking readFileSync, writeFileSync, and mkdirSync calls from the
agent spawn hot path, replacing them with async fs/promises equivalents to
avoid stalling the Node.js event loop during credential operations.
This commit is contained in:
Lukas May
2026-03-04 12:25:05 +01:00
parent bd0aec4499
commit a2afc2e1fd
3 changed files with 17 additions and 16 deletions

View File

@@ -1,15 +1,15 @@
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
export function setupAccountConfigDir( export async function setupAccountConfigDir(
configDir: string, configDir: string,
extracted: { configJson: object; credentials: string }, extracted: { configJson: object; credentials: string },
): void { ): Promise<void> {
mkdirSync(configDir, { recursive: true }); await mkdir(configDir, { recursive: true });
// Write .claude.json // Write .claude.json
writeFileSync(join(configDir, '.claude.json'), JSON.stringify(extracted.configJson, null, 2)); await writeFile(join(configDir, '.claude.json'), JSON.stringify(extracted.configJson, null, 2));
// Write .credentials.json // Write .credentials.json
writeFileSync(join(configDir, '.credentials.json'), extracted.credentials); await writeFile(join(configDir, '.credentials.json'), extracted.credentials);
} }

View File

@@ -6,7 +6,8 @@
* ensuring they're fresh, and marking accounts as exhausted on failure. * ensuring they're fresh, and marking accounts as exhausted on failure.
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AccountRepository } from '../db/repositories/account-repository.js'; import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { AccountCredentialManager } from './credentials/types.js'; import type { AccountCredentialManager } from './credentials/types.js';
@@ -50,9 +51,9 @@ export class CredentialHandler {
* Write account credentials from DB to the convention-based config directory. * Write account credentials from DB to the convention-based config directory.
* Must be called before ensureCredentials so the files exist on disk. * Must be called before ensureCredentials so the files exist on disk.
*/ */
writeCredentialsToDisk(account: Account, configDir: string): void { async writeCredentialsToDisk(account: Account, configDir: string): Promise<void> {
if (account.configJson && account.credentials) { if (account.configJson && account.credentials) {
setupAccountConfigDir(configDir, { await setupAccountConfigDir(configDir, {
configJson: JSON.parse(account.configJson), configJson: JSON.parse(account.configJson),
credentials: account.credentials, credentials: account.credentials,
}); });
@@ -70,7 +71,7 @@ export class CredentialHandler {
if (!this.accountRepository) return; if (!this.accountRepository) return;
try { try {
const credPath = join(configDir, '.credentials.json'); const credPath = join(configDir, '.credentials.json');
const credentials = readFileSync(credPath, 'utf-8'); const credentials = await readFile(credPath, 'utf-8');
await this.accountRepository.updateCredentials(accountId, credentials); await this.accountRepository.updateCredentials(accountId, credentials);
log.debug({ accountId }, 'persisted refreshed credentials back to DB'); log.debug({ accountId }, 'persisted refreshed credentials back to DB');
} catch (err) { } catch (err) {
@@ -97,11 +98,11 @@ export class CredentialHandler {
* Returns null if credentials file is missing or malformed. * Returns null if credentials file is missing or malformed.
* Used for CLAUDE_CODE_OAUTH_TOKEN env var injection. * Used for CLAUDE_CODE_OAUTH_TOKEN env var injection.
*/ */
readAccessToken(configDir: string): string | null { async readAccessToken(configDir: string): Promise<string | 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);
return parsed.claudeAiOauth?.accessToken ?? null; return parsed.claudeAiOauth?.accessToken ?? null;
} catch { } catch {
@@ -126,7 +127,7 @@ export class CredentialHandler {
accountConfigDir = getAccountConfigDir(this.workspaceRoot, accountId); accountConfigDir = getAccountConfigDir(this.workspaceRoot, accountId);
const account = await this.accountRepository.findById(accountId); const account = await this.accountRepository.findById(accountId);
if (account) { if (account) {
this.writeCredentialsToDisk(account, accountConfigDir); await this.writeCredentialsToDisk(account, accountConfigDir);
} }
processEnv[provider.configDirEnv] = accountConfigDir; processEnv[provider.configDirEnv] = accountConfigDir;
@@ -138,7 +139,7 @@ export class CredentialHandler {
await this.persistRefreshedCredentials(accountId, accountConfigDir); await this.persistRefreshedCredentials(accountId, accountConfigDir);
} }
const accessToken = this.readAccessToken(accountConfigDir); const accessToken = await this.readAccessToken(accountConfigDir);
if (accessToken) { if (accessToken) {
processEnv['CLAUDE_CODE_OAUTH_TOKEN'] = accessToken; processEnv['CLAUDE_CODE_OAUTH_TOKEN'] = accessToken;
log.debug({ accountId }, 'CLAUDE_CODE_OAUTH_TOKEN injected'); log.debug({ accountId }, 'CLAUDE_CODE_OAUTH_TOKEN injected');
@@ -191,7 +192,7 @@ export class CredentialHandler {
// Write credentials and ensure they're fresh // Write credentials and ensure they're fresh
const nextConfigDir = getAccountConfigDir(this.workspaceRoot, nextAccount.id); const nextConfigDir = getAccountConfigDir(this.workspaceRoot, nextAccount.id);
this.writeCredentialsToDisk(nextAccount, nextConfigDir); await this.writeCredentialsToDisk(nextAccount, nextConfigDir);
const { valid, refreshed } = await this.ensureCredentials(nextConfigDir, nextAccount.id); const { valid, refreshed } = await this.ensureCredentials(nextConfigDir, nextAccount.id);
if (!valid) { if (!valid) {
log.warn({ newAccountId: nextAccount.id }, 'failed to refresh failover account credentials'); log.warn({ newAccountId: nextAccount.id }, 'failed to refresh failover account credentials');

View File

@@ -212,7 +212,7 @@ export class MultiProviderAgentManager implements AgentManager {
accountId = accountResult.accountId; accountId = accountResult.accountId;
accountConfigDir = accountResult.configDir; accountConfigDir = accountResult.configDir;
this.credentialHandler.writeCredentialsToDisk(accountResult.account, accountConfigDir); await this.credentialHandler.writeCredentialsToDisk(accountResult.account, accountConfigDir);
const { valid, refreshed } = await this.credentialHandler.ensureCredentials(accountConfigDir, accountId); const { valid, refreshed } = await this.credentialHandler.ensureCredentials(accountConfigDir, accountId);
if (!valid) { if (!valid) {
log.warn({ alias, accountId }, 'failed to refresh account credentials, proceeding anyway'); log.warn({ alias, accountId }, 'failed to refresh account credentials, proceeding anyway');