fix: auth hang when select qwen-oauth (#684)

This commit is contained in:
Mingholy
2025-09-23 14:30:22 +08:00
committed by GitHub
parent deb99a3b21
commit 9c1d7228cb
4 changed files with 96 additions and 41 deletions

13
.vscode/launch.json vendored
View File

@@ -101,6 +101,13 @@
"env": { "env": {
"GEMINI_SANDBOX": "false" "GEMINI_SANDBOX": "false"
} }
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
} }
], ],
"inputs": [ "inputs": [
@@ -115,6 +122,12 @@
"type": "promptString", "type": "promptString",
"description": "Enter your prompt for non-interactive mode", "description": "Enter your prompt for non-interactive mode",
"default": "Explain this code" "default": "Explain this code"
},
{
"id": "debugPort",
"type": "promptString",
"description": "Enter the debug port number (default: 9229)",
"default": "9229"
} }
] ]
} }

View File

@@ -712,8 +712,6 @@ async function authWithQwenDeviceFlow(
`Polling... (attempt ${attempt + 1}/${maxAttempts})`, `Polling... (attempt ${attempt + 1}/${maxAttempts})`,
); );
process.stdout.write('.');
// Wait with cancellation check every 100ms // Wait with cancellation check every 100ms
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const checkInterval = 100; // Check every 100ms const checkInterval = 100; // Check every 100ms

View File

@@ -901,5 +901,37 @@ describe('SharedTokenManager', () => {
); );
} }
}); });
it('should properly clean up timeout when file operation completes before timeout', async () => {
const tokenManager = SharedTokenManager.getInstance();
tokenManager.clearCache();
const mockClient = {
getCredentials: vi.fn().mockReturnValue(null),
setCredentials: vi.fn(),
getAccessToken: vi.fn(),
requestDeviceAuthorization: vi.fn(),
pollDeviceToken: vi.fn(),
refreshAccessToken: vi.fn(),
};
// Mock clearTimeout to verify it's called
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
// Mock file stat to resolve quickly (before timeout)
mockFs.stat.mockResolvedValue({ mtimeMs: 12345 } as Stats);
// Call checkAndReloadIfNeeded which uses withTimeout internally
const checkMethod = getPrivateProperty(
tokenManager,
'checkAndReloadIfNeeded',
) as (client?: IQwenOAuth2Client) => Promise<void>;
await checkMethod.call(tokenManager, mockClient);
// Verify that clearTimeout was called to clean up the timer
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
}); });
}); });

View File

@@ -290,6 +290,36 @@ export class SharedTokenManager {
} }
} }
/**
* Utility method to add timeout to any promise operation
* Properly cleans up the timeout when the promise completes
*/
private withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationType = 'Operation',
): Promise<T> {
let timeoutId: NodeJS.Timeout;
return Promise.race([
promise.finally(() => {
// Clear timeout when main promise completes (success or failure)
if (timeoutId) {
clearTimeout(timeoutId);
}
}),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() =>
reject(
new Error(`${operationType} timed out after ${timeoutMs}ms`),
),
timeoutMs,
);
}),
]);
}
/** /**
* Perform the actual file check and reload operation * Perform the actual file check and reload operation
* This is separated to enable proper promise-based synchronization * This is separated to enable proper promise-based synchronization
@@ -303,25 +333,12 @@ export class SharedTokenManager {
try { try {
const filePath = this.getCredentialFilePath(); const filePath = this.getCredentialFilePath();
// Add timeout to file stat operation
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> =>
Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(`File operation timed out after ${timeoutMs}ms`),
),
timeoutMs,
),
),
]);
const stats = await withTimeout(fs.stat(filePath), 3000); const stats = await this.withTimeout(
fs.stat(filePath),
3000,
'File operation',
);
const fileModTime = stats.mtimeMs; const fileModTime = stats.mtimeMs;
// Reload credentials if file has been modified since last cache // Reload credentials if file has been modified since last cache
@@ -451,7 +468,7 @@ export class SharedTokenManager {
// Check if we have a refresh token before attempting refresh // Check if we have a refresh token before attempting refresh
const currentCredentials = qwenClient.getCredentials(); const currentCredentials = qwenClient.getCredentials();
if (!currentCredentials.refresh_token) { if (!currentCredentials.refresh_token) {
console.debug('create a NO_REFRESH_TOKEN error'); // console.debug('create a NO_REFRESH_TOKEN error');
throw new TokenManagerError( throw new TokenManagerError(
TokenError.NO_REFRESH_TOKEN, TokenError.NO_REFRESH_TOKEN,
'No refresh token available for token refresh', 'No refresh token available for token refresh',
@@ -589,26 +606,12 @@ export class SharedTokenManager {
const dirPath = path.dirname(filePath); const dirPath = path.dirname(filePath);
const tempPath = `${filePath}.tmp.${randomUUID()}`; const tempPath = `${filePath}.tmp.${randomUUID()}`;
// Add timeout wrapper for file operations
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> =>
Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
]);
// Create directory with restricted permissions // Create directory with restricted permissions
try { try {
await withTimeout( await this.withTimeout(
fs.mkdir(dirPath, { recursive: true, mode: 0o700 }), fs.mkdir(dirPath, { recursive: true, mode: 0o700 }),
5000, 5000,
'File operation',
); );
} catch (error) { } catch (error) {
throw new TokenManagerError( throw new TokenManagerError(
@@ -622,21 +625,30 @@ export class SharedTokenManager {
try { try {
// Write to temporary file first with restricted permissions // Write to temporary file first with restricted permissions
await withTimeout( await this.withTimeout(
fs.writeFile(tempPath, credString, { mode: 0o600 }), fs.writeFile(tempPath, credString, { mode: 0o600 }),
5000, 5000,
'File operation',
); );
// Atomic move to final location // Atomic move to final location
await withTimeout(fs.rename(tempPath, filePath), 5000); await this.withTimeout(
fs.rename(tempPath, filePath),
5000,
'File operation',
);
// Update cached file modification time atomically after successful write // Update cached file modification time atomically after successful write
const stats = await withTimeout(fs.stat(filePath), 5000); const stats = await this.withTimeout(
fs.stat(filePath),
5000,
'File operation',
);
this.memoryCache.fileModTime = stats.mtimeMs; this.memoryCache.fileModTime = stats.mtimeMs;
} catch (error) { } catch (error) {
// Clean up temp file if it exists // Clean up temp file if it exists
try { try {
await withTimeout(fs.unlink(tempPath), 1000); await this.withTimeout(fs.unlink(tempPath), 1000, 'File operation');
} catch (_cleanupError) { } catch (_cleanupError) {
// Ignore cleanup errors - temp file might not exist // Ignore cleanup errors - temp file might not exist
} }