fix: unexpected re-auth when auth-token is expired (#549)

This commit is contained in:
Mingholy
2025-09-09 11:34:05 +08:00
committed by GitHub
parent 60c136ad67
commit 621fe2e8ba
5 changed files with 448 additions and 144 deletions

View File

@@ -15,6 +15,7 @@ import {
type TokenRefreshData,
type ErrorData,
isErrorResponse,
CredentialsClearRequiredError,
} from './qwenOAuth2.js';
// File System Configuration
@@ -126,6 +127,11 @@ export class SharedTokenManager {
*/
private refreshPromise: Promise<QwenCredentials> | null = null;
/**
* Promise tracking any ongoing file check operation to prevent concurrent checks
*/
private checkPromise: Promise<void> | null = null;
/**
* Whether cleanup handlers have been registered
*/
@@ -200,7 +206,7 @@ export class SharedTokenManager {
): Promise<QwenCredentials> {
try {
// Check if credentials file has been updated by other sessions
await this.checkAndReloadIfNeeded();
await this.checkAndReloadIfNeeded(qwenClient);
// Return valid cached credentials if available (unless force refresh is requested)
if (
@@ -211,23 +217,26 @@ export class SharedTokenManager {
return this.memoryCache.credentials;
}
// If refresh is already in progress, wait for it to complete
if (this.refreshPromise) {
return this.refreshPromise;
// Use a local promise variable to avoid race conditions
let currentRefreshPromise = this.refreshPromise;
if (!currentRefreshPromise) {
// Start new refresh operation with distributed locking
currentRefreshPromise = this.performTokenRefresh(
qwenClient,
forceRefresh,
);
this.refreshPromise = currentRefreshPromise;
}
// Start new refresh operation with distributed locking
this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh);
try {
const credentials = await this.refreshPromise;
return credentials;
} catch (error) {
// Ensure refreshPromise is cleared on error before re-throwing
this.refreshPromise = null;
throw error;
// Wait for the refresh to complete
const result = await currentRefreshPromise;
return result;
} finally {
this.refreshPromise = null;
if (this.refreshPromise === currentRefreshPromise) {
this.refreshPromise = null;
}
}
} catch (error) {
// Convert generic errors to TokenManagerError for better error handling
@@ -245,8 +254,22 @@ export class SharedTokenManager {
/**
* Check if the credentials file was updated by another process and reload if so
* Uses promise-based locking to prevent concurrent file checks
*/
private async checkAndReloadIfNeeded(): Promise<void> {
private async checkAndReloadIfNeeded(
qwenClient?: IQwenOAuth2Client,
): Promise<void> {
// If there's already an ongoing check, wait for it to complete
if (this.checkPromise) {
await this.checkPromise;
return;
}
// If there's an ongoing refresh, skip the file check as refresh will handle it
if (this.refreshPromise) {
return;
}
const now = Date.now();
// Limit check frequency to avoid excessive disk I/O
@@ -254,7 +277,26 @@ export class SharedTokenManager {
return;
}
this.memoryCache.lastCheck = now;
// Start the check operation and store the promise
this.checkPromise = this.performFileCheck(qwenClient, now);
try {
await this.checkPromise;
} finally {
this.checkPromise = null;
}
}
/**
* Perform the actual file check and reload operation
* This is separated to enable proper promise-based synchronization
*/
private async performFileCheck(
qwenClient: IQwenOAuth2Client | undefined,
checkTime: number,
): Promise<void> {
// Update lastCheck atomically at the start to prevent other calls from proceeding
this.memoryCache.lastCheck = checkTime;
try {
const filePath = this.getCredentialFilePath();
@@ -263,7 +305,8 @@ export class SharedTokenManager {
// Reload credentials if file has been modified since last cache
if (fileModTime > this.memoryCache.fileModTime) {
await this.reloadCredentialsFromFile();
await this.reloadCredentialsFromFile(qwenClient);
// Update fileModTime only after successful reload
this.memoryCache.fileModTime = fileModTime;
}
} catch (error) {
@@ -273,9 +316,8 @@ export class SharedTokenManager {
'code' in error &&
error.code !== 'ENOENT'
) {
// Clear cache for non-missing file errors
this.memoryCache.credentials = null;
this.memoryCache.fileModTime = 0;
// Clear cache atomically for non-missing file errors
this.updateCacheState(null, 0, checkTime);
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
@@ -291,15 +333,71 @@ export class SharedTokenManager {
}
/**
* Load credentials from the file system into memory cache
* Force a file check without time-based throttling (used during refresh operations)
*/
private async reloadCredentialsFromFile(): Promise<void> {
private async forceFileCheck(qwenClient?: IQwenOAuth2Client): Promise<void> {
try {
const filePath = this.getCredentialFilePath();
const stats = await fs.stat(filePath);
const fileModTime = stats.mtimeMs;
// Reload credentials if file has been modified since last cache
if (fileModTime > this.memoryCache.fileModTime) {
await this.reloadCredentialsFromFile(qwenClient);
// Update cache state atomically
this.memoryCache.fileModTime = fileModTime;
this.memoryCache.lastCheck = Date.now();
}
} catch (error) {
// Handle file access errors
if (
error instanceof Error &&
'code' in error &&
error.code !== 'ENOENT'
) {
// Clear cache atomically for non-missing file errors
this.updateCacheState(null, 0);
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to access credentials file during refresh: ${error.message}`,
error,
);
}
// For missing files (ENOENT), just reset file modification time
this.memoryCache.fileModTime = 0;
}
}
/**
* Load credentials from the file system into memory cache and sync with qwenClient
*/
private async reloadCredentialsFromFile(
qwenClient?: IQwenOAuth2Client,
): Promise<void> {
try {
const filePath = this.getCredentialFilePath();
const content = await fs.readFile(filePath, 'utf-8');
const parsedData = JSON.parse(content);
const credentials = validateCredentials(parsedData);
// Store previous state for rollback
const previousCredentials = this.memoryCache.credentials;
// Update memory cache first
this.memoryCache.credentials = credentials;
// Sync with qwenClient atomically - rollback on failure
try {
if (qwenClient) {
qwenClient.setCredentials(credentials);
}
} catch (clientError) {
// Rollback memory cache on client sync failure
this.memoryCache.credentials = previousCredentials;
throw clientError;
}
} catch (error) {
// Log validation errors for debugging but don't throw
if (
@@ -308,6 +406,7 @@ export class SharedTokenManager {
) {
console.warn(`Failed to validate credentials file: ${error.message}`);
}
// Clear credentials but preserve other cache state
this.memoryCache.credentials = null;
}
}
@@ -330,6 +429,7 @@ export class SharedTokenManager {
// Check if we have a refresh token before attempting refresh
const currentCredentials = qwenClient.getCredentials();
if (!currentCredentials.refresh_token) {
console.debug('create a NO_REFRESH_TOKEN error');
throw new TokenManagerError(
TokenError.NO_REFRESH_TOKEN,
'No refresh token available for token refresh',
@@ -340,7 +440,8 @@ export class SharedTokenManager {
await this.acquireLock(lockPath);
// Double-check if another process already refreshed the token (unless force refresh is requested)
await this.checkAndReloadIfNeeded();
// Skip the time-based throttling since we're already in a locked refresh operation
await this.forceFileCheck(qwenClient);
// Use refreshed credentials if they're now valid (unless force refresh is requested)
if (
@@ -348,7 +449,7 @@ export class SharedTokenManager {
this.memoryCache.credentials &&
this.isTokenValid(this.memoryCache.credentials)
) {
qwenClient.setCredentials(this.memoryCache.credentials);
// No need to call qwenClient.setCredentials here as checkAndReloadIfNeeded already did it
return this.memoryCache.credentials;
}
@@ -382,7 +483,7 @@ export class SharedTokenManager {
expiry_date: Date.now() + tokenData.expires_in * 1000,
};
// Update memory cache and client credentials
// Update memory cache and client credentials atomically
this.memoryCache.credentials = credentials;
qwenClient.setCredentials(credentials);
@@ -391,6 +492,24 @@ export class SharedTokenManager {
return credentials;
} catch (error) {
// Handle credentials clear required error (400 status from refresh)
if (error instanceof CredentialsClearRequiredError) {
console.debug(
'SharedTokenManager: Clearing memory cache due to credentials clear requirement',
);
// Clear memory cache when credentials need to be cleared
this.memoryCache.credentials = null;
this.memoryCache.fileModTime = 0;
// Reset any ongoing refresh promise as the credentials are no longer valid
this.refreshPromise = null;
throw new TokenManagerError(
TokenError.REFRESH_FAILED,
error.message,
error,
);
}
if (error instanceof TokenManagerError) {
throw error;
}
@@ -430,6 +549,7 @@ export class SharedTokenManager {
): Promise<void> {
const filePath = this.getCredentialFilePath();
const dirPath = path.dirname(filePath);
const tempPath = `${filePath}.tmp.${randomUUID()}`;
// Create directory with restricted permissions
try {
@@ -445,26 +565,29 @@ export class SharedTokenManager {
const credString = JSON.stringify(credentials, null, 2);
try {
// Write file with restricted permissions (owner read/write only)
await fs.writeFile(filePath, credString, { mode: 0o600 });
// Write to temporary file first with restricted permissions
await fs.writeFile(tempPath, credString, { mode: 0o600 });
// Atomic move to final location
await fs.rename(tempPath, filePath);
// Update cached file modification time atomically after successful write
const stats = await fs.stat(filePath);
this.memoryCache.fileModTime = stats.mtimeMs;
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
} catch (_cleanupError) {
// Ignore cleanup errors - temp file might not exist
}
throw new TokenManagerError(
TokenError.FILE_ACCESS_ERROR,
`Failed to write credentials file: ${error instanceof Error ? error.message : String(error)}`,
error,
);
}
// Update cached file modification time to avoid unnecessary reloads
try {
const stats = await fs.stat(filePath);
this.memoryCache.fileModTime = stats.mtimeMs;
} catch (error) {
// Non-fatal error, just log it
console.warn(
`Failed to update file modification time: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
@@ -522,18 +645,23 @@ export class SharedTokenManager {
// Remove stale locks that exceed timeout
if (lockAge > LOCK_TIMEOUT_MS) {
// Use atomic rename operation to avoid race condition
const tempPath = `${lockPath}.stale.${randomUUID()}`;
try {
await fs.unlink(lockPath);
// Atomic move to temporary location
await fs.rename(lockPath, tempPath);
// Clean up the temporary file
await fs.unlink(tempPath);
console.warn(
`Removed stale lock file: ${lockPath} (age: ${lockAge}ms)`,
);
continue; // Retry lock acquisition
} catch (unlinkError) {
// Log the error but continue trying - another process might have removed it
continue; // Retry lock acquisition immediately
} catch (renameError) {
// Lock might have been removed by another process, continue trying
console.warn(
`Failed to remove stale lock file ${lockPath}: ${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`,
`Failed to remove stale lock file ${lockPath}: ${renameError instanceof Error ? renameError.message : String(renameError)}`,
);
// Still continue - the lock might have been removed by another process
// Continue - the lock might have been removed by another process
}
}
} catch (statError) {
@@ -580,16 +708,31 @@ export class SharedTokenManager {
}
}
/**
* Atomically update cache state to prevent inconsistent intermediate states
* @param credentials - New credentials to cache
* @param fileModTime - File modification time
* @param lastCheck - Last check timestamp (optional, defaults to current time)
*/
private updateCacheState(
credentials: QwenCredentials | null,
fileModTime: number,
lastCheck?: number,
): void {
this.memoryCache = {
credentials,
fileModTime,
lastCheck: lastCheck ?? Date.now(),
};
}
/**
* Clear all cached data and reset the manager to initial state
*/
clearCache(): void {
this.memoryCache = {
credentials: null,
fileModTime: 0,
lastCheck: 0,
};
this.updateCacheState(null, 0, 0);
this.refreshPromise = null;
this.checkPromise = null;
}
/**