Skip to content

feat: Rewrite CAPTCHA handling (v2) + async concurrency support#271

Open
aqeel-spec wants to merge 3 commits intogcui-art:mainfrom
aqeel-spec:feat/captcha-v2-and-async-concurrency
Open

feat: Rewrite CAPTCHA handling (v2) + async concurrency support#271
aqeel-spec wants to merge 3 commits intogcui-art:mainfrom
aqeel-spec:feat/captcha-v2-and-async-concurrency

Conversation

@aqeel-spec
Copy link
Copy Markdown

Summary

This PR addresses two major issues with the current codebase:

  1. CAPTCHA handling is broken — Suno changed their UI, causing hardcoded selectors (.custom-textarea) to fail with TimeoutError.
  2. No concurrency support — Concurrent API requests race on shared state (token refresh, browser sessions), causing crashes and wasted resources.

Changes

1. CAPTCHA Handling Rewrite (v2)

Problem: The Suno website UI changed, breaking the existing CAPTCHA flow. The hardcoded .custom-textarea selector no longer exists, causing TimeoutError: Timeout 30000ms exceeded.

Solution:

  • Fallback selectors: 10 selectors for prompt input, 8 for Create button — resilient against UI changes
  • Debug snapshot system: Saves HTML, screenshots, request logs, frame lists, and interactive element enumeration into a debug/ folder at every step
  • Multi-CAPTCHA provider detection: Detects hCaptcha, reCAPTCHA, Cloudflare Turnstile, and Arkose/FunCaptcha
  • Popup auto-dismissal: Closes modals/banners before interacting with the page
  • Retry logic: Retries Create button click if CAPTCHA doesn't appear on first attempt
  • Race condition fix: Sets up route interception before clicking Create, so token is never missed
  • Error propagation fix: Captcha solver errors are properly wired to the outer promise (no more unhandled rejections)

2. Async Concurrency Support

Problem: When multiple requests hit the API simultaneously, they all race on keepAlive() token refresh and getCaptcha() browser launches.

Solution:

  • AsyncMutex — Serializes keepAlive() token refresh and getCaptcha() browser sessions
  • AsyncSemaphore — Limits concurrent generation requests (configurable via CONCURRENT_LIMIT env var, default: 3)
  • Token refresh cooldownkeepAlive() skips refresh if token was refreshed within 30 seconds
  • Request ID tracking — Each request gets [req-N] prefix in logs for traceability
  • Double-check pattern — After acquiring CAPTCHA mutex, re-checks if CAPTCHA is still needed

3. Utility Improvements (utils.ts)

  • sleep() no longer logs calls < 1 second (eliminates polling log spam)
  • waitForRequests() detects 6 URL patterns across 4 CAPTCHA providers
  • Timeout increased from 60s to 120s

Configuration

New optional env variable:

# Max concurrent generation requests (default: 3)
CONCURRENT_LIMIT=3

Testing

  • TypeScript: No type errors
  • ESLint: No warnings or errors
  • Tested with concurrent /api/custom_generate requests — token refresh is serialized, CAPTCHA solving is serialized, generation requests are semaphore-limited

Files Changed

  • src/lib/utils.ts — AsyncMutex, AsyncSemaphore, multi-provider CAPTCHA detection, sleep log fix
  • src/lib/SunoApi.ts — getCaptcha v2 rewrite, concurrency primitives integration

…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)
Copilot AI review requested due to automatic review settings March 1, 2026 07:58
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 1, 2026

@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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / AsyncSemaphore primitives and use them to serialize keepAlive() 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.

Comment on lines +107 to 112
// 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);
}
});
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +187
constructor(private maxConcurrency: number) {}

async acquire(): Promise<() => void> {
if (this.currentCount < this.maxConcurrency) {
this.currentCount++;
return () => this.release();
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +354 to +366
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}.*`);
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +587 to +592
route.abort();
controller.abort();
browser.browser()?.close();
resolve(postData?.token || null);
} catch (err) {
reject(err);
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +618 to +639
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.'
);
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +574 to +577
let resolveOuter: (token: string | null) => void = () => {};

const tokenPromise = new Promise<string | null>((resolve, reject) => {
resolveOuter = resolve;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
let resolveOuter: (token: string | null) => void = () => {};
const tokenPromise = new Promise<string | null>((resolve, reject) => {
resolveOuter = resolve;
const tokenPromise = new Promise<string | null>((resolve, reject) => {

Copilot uses AI. Check for mistakes.
- 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
@lumos42
Copy link
Copy Markdown

lumos42 commented Apr 2, 2026

This PR fixed the captcha issue for me. Thanks for the contribution @aqeel-spec ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants