feat: Rewrite CAPTCHA handling (v2) + async concurrency support#271
feat: Rewrite CAPTCHA handling (v2) + async concurrency support#271aqeel-spec wants to merge 3 commits intogcui-art:mainfrom
Conversation
…HA detection - Add AsyncMutex class for serializing async critical sections - Add AsyncSemaphore class for limiting concurrent async operations - Update waitForRequests() to detect hCaptcha, reCAPTCHA, Turnstile, and Arkose - Increase CAPTCHA wait timeout from 60s to 120s - Suppress sleep() log messages for calls < 1 second
…y support CAPTCHA v2: - Rewrite getCaptcha() with debug snapshots (HTML, screenshots, request/frame logs) - Add saveDebugSnapshot() helper for page state capture into debug/ folder - Add waitForCaptchaFrame() to detect hCaptcha, reCAPTCHA, Turnstile, Arkose - Add waitForAnyVisibleLocator() with fallback selector lists - Add interactive element enumeration for debugging UI changes - Auto-dismiss popups/modals before interacting with page - Set up route interception before clicking Create button - Retry logic for Create button clicks - Fix unhandled promise rejection in captcha solver Async concurrency: - Add keepAliveMutex with 30s cooldown to prevent token refresh storms - Add captchaMutex to serialize browser sessions (one at a time) - Add requestSemaphore to limit concurrent generations (CONCURRENT_LIMIT env var) - Add request ID tracking ([req-N]) for log traceability - Re-check captchaRequired() after acquiring mutex (skip if already solved)
|
@aqeel-spec is attempting to deploy a commit to the Linkly AI LLC's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR rewrites the Suno “create” page CAPTCHA triggering/solving flow to be resilient to UI changes, and adds async concurrency controls to prevent races across token refresh, CAPTCHA browser sessions, and generation requests.
Changes:
- Add multi-selector UI probing + multi-provider CAPTCHA detection, plus a debug snapshot system to aid troubleshooting.
- Introduce
AsyncMutex/AsyncSemaphoreprimitives and use them to serializekeepAlive()and CAPTCHA sessions and to limit concurrent generation calls. - Improve request-waiting utilities (reduced sleep log spam, broader CAPTCHA request detection, longer timeouts).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/lib/utils.ts |
Reduces sleep() log spam, expands CAPTCHA request detection, and introduces AsyncMutex/AsyncSemaphore. |
src/lib/SunoApi.ts |
Integrates concurrency controls and replaces the CAPTCHA handling with a more robust “v2” browser-driven flow + debug snapshots. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Cleanup the initial timeout if a CAPTCHA request occurs | ||
| page.on('request', (request: { url: () => string }) => { | ||
| if (urlPattern.test(request.url())) { | ||
| if (matchesCaptchaUrl(request.url())) { | ||
| clearTimeout(initialTimeout); | ||
| } | ||
| }); |
There was a problem hiding this comment.
waitForRequests() adds an extra anonymous page.on('request', ...) listener (to clear the initial timeout) that is never removed in cleanupListeners(). Because waitForRequests can be called repeatedly (e.g., in the CAPTCHA loop), this will accumulate listeners and can cause memory/perf issues. Store the handler in a named function and unregister it in cleanupListeners() (or fold the clearTimeout logic into onRequest).
| constructor(private maxConcurrency: number) {} | ||
|
|
||
| async acquire(): Promise<() => void> { | ||
| if (this.currentCount < this.maxConcurrency) { | ||
| this.currentCount++; | ||
| return () => this.release(); | ||
| } |
There was a problem hiding this comment.
AsyncSemaphore doesn't validate maxConcurrency. If it is 0, negative, or NaN (e.g., CONCURRENT_LIMIT is set to a non-number), acquire() will queue forever and requests will deadlock. Clamp/validate in the constructor (e.g., default to 1 or 3 when invalid) and consider throwing on invalid values.
| private async saveDebugSnapshot(page: Page, label: string, requestLog?: string[]): Promise<void> { | ||
| const debugDir = path.join(process.cwd(), 'debug'); | ||
| try { | ||
| await fs.mkdir(debugDir, { recursive: true }); | ||
| await fs.writeFile(path.join(debugDir, `${label}.html`), await page.content()); | ||
| await page.screenshot({ path: path.join(debugDir, `${label}.png`), fullPage: true }); | ||
| if (requestLog) { | ||
| await fs.writeFile(path.join(debugDir, `${label}-requests.log`), requestLog.join('\n')); | ||
| } | ||
| // List all frames | ||
| const frameUrls = page.frames().map(f => f.url()); | ||
| await fs.writeFile(path.join(debugDir, `${label}-frames.log`), frameUrls.join('\n')); | ||
| logger.info(`Debug snapshot saved: debug/${label}.*`); |
There was a problem hiding this comment.
saveDebugSnapshot() always writes full HTML, screenshots, and request logs to a local debug/ folder. In server/production usage this can leak sensitive data (HTML may include user/session info) and can fill disk over time. Gate this behind an env flag (disabled by default) and/or add retention/size limits + redaction of sensitive headers/query params in requestLog.
| route.abort(); | ||
| controller.abort(); | ||
| browser.browser()?.close(); | ||
| resolve(postData?.token || null); | ||
| } catch (err) { | ||
| reject(err); |
There was a problem hiding this comment.
The page.route('**/api/generate/v2/**', ...) handler doesn't guarantee that the route is handled on the error path. If an exception occurs before route.abort() (e.g., postDataJSON() throws), the intercepted request can hang. Ensure the handler always calls route.abort()/route.continue() in a finally block, and consider awaiting browser.close() to avoid leaked resources.
| route.abort(); | |
| controller.abort(); | |
| browser.browser()?.close(); | |
| resolve(postData?.token || null); | |
| } catch (err) { | |
| reject(err); | |
| resolve(postData?.token || null); | |
| } catch (err) { | |
| reject(err); | |
| } finally { | |
| // Always handle the intercepted route and clean up resources | |
| try { | |
| await route.abort(); | |
| } catch { | |
| // ignore abort errors | |
| } | |
| try { | |
| controller.abort(); | |
| } catch { | |
| // ignore abort controller errors | |
| } | |
| try { | |
| const playwrightBrowser = browser.browser && typeof browser.browser === 'function' ? browser.browser() : null; | |
| if (playwrightBrowser && typeof playwrightBrowser.close === 'function') { | |
| await playwrightBrowser.close(); | |
| } | |
| } catch { | |
| // ignore browser close errors | |
| } |
| if (!captchaType) { | ||
| // Check if the generate API was called without a CAPTCHA (maybe CAPTCHA wasn't needed after all) | ||
| logger.warn('No CAPTCHA iframe found. Checking if generation proceeded without CAPTCHA...'); | ||
| // Give the tokenPromise a chance to resolve | ||
| const raceResult = await Promise.race([ | ||
| tokenPromise.then(t => ({ type: 'token' as const, value: t })), | ||
| new Promise<{ type: 'timeout' }>(r => setTimeout(() => r({ type: 'timeout' }), 10000)), | ||
| ]); | ||
| if (raceResult.type === 'token') { | ||
| logger.info('Generation proceeded without visible CAPTCHA'); | ||
| return raceResult.value; | ||
| } | ||
|
|
||
| // Truly no CAPTCHA and no generation | ||
| await this.saveDebugSnapshot(page, '07-no-captcha-final', requestLog); | ||
| await browser.browser()?.close(); | ||
| throw new Error( | ||
| 'No CAPTCHA appeared and generation did not proceed. ' | ||
| + 'The Suno UI may have changed significantly. ' | ||
| + 'Check the debug/ folder for HTML, screenshots, interactive elements, and request logs.' | ||
| ); | ||
| } |
There was a problem hiding this comment.
_solveCaptcha() returns tokenPromise with no timeout or cancellation path. If the generate route never fires (UI change / click swallowed / CAPTCHA flow aborts) the promise can hang indefinitely, tying up the CAPTCHA mutex and blocking other requests. Add an overall timeout that rejects, closes the browser, and removes the route handler; also consider rejecting when the CAPTCHA loop exits with a non-route-related "been closed" error.
| let resolveOuter: (token: string | null) => void = () => {}; | ||
|
|
||
| const tokenPromise = new Promise<string | null>((resolve, reject) => { | ||
| resolveOuter = resolve; |
There was a problem hiding this comment.
resolveOuter is assigned but never used. This makes the tokenPromise wiring harder to follow and risks lint/noUnusedLocals issues if enabled later. Remove it (keep only the captured reject function) or use it consistently for early-success paths.
| let resolveOuter: (token: string | null) => void = () => {}; | |
| const tokenPromise = new Promise<string | null>((resolve, reject) => { | |
| resolveOuter = resolve; | |
| const tokenPromise = new Promise<string | null>((resolve, reject) => { |
- Start solving loop with wait=false on first iteration so
waitForRequests() is not called when challenge images already loaded
- Set wait=true in non-drag branch after submitting tiles so subsequent
iterations correctly wait for new challenge images to load
- Add challenge.waitFor({ state: 'visible' }) before interacting with
the challenge container to prevent 30s innerText timeout when the
hCaptcha iframe content is not yet rendered
|
This PR fixed the captcha issue for me. Thanks for the contribution @aqeel-spec ❤️ |
Summary
This PR addresses two major issues with the current codebase:
.custom-textarea) to fail withTimeoutError.Changes
1. CAPTCHA Handling Rewrite (v2)
Problem: The Suno website UI changed, breaking the existing CAPTCHA flow. The hardcoded
.custom-textareaselector no longer exists, causingTimeoutError: Timeout 30000ms exceeded.Solution:
debug/folder at every step2. Async Concurrency Support
Problem: When multiple requests hit the API simultaneously, they all race on
keepAlive()token refresh andgetCaptcha()browser launches.Solution:
AsyncMutex— SerializeskeepAlive()token refresh andgetCaptcha()browser sessionsAsyncSemaphore— Limits concurrent generation requests (configurable viaCONCURRENT_LIMITenv var, default: 3)keepAlive()skips refresh if token was refreshed within 30 seconds[req-N]prefix in logs for traceability3. Utility Improvements (
utils.ts)sleep()no longer logs calls < 1 second (eliminates polling log spam)waitForRequests()detects 6 URL patterns across 4 CAPTCHA providersConfiguration
New optional env variable:
Testing
/api/custom_generaterequests — token refresh is serialized, CAPTCHA solving is serialized, generation requests are semaphore-limitedFiles Changed
src/lib/utils.ts— AsyncMutex, AsyncSemaphore, multi-provider CAPTCHA detection, sleep log fixsrc/lib/SunoApi.ts— getCaptcha v2 rewrite, concurrency primitives integration