# How to Debug CDP Timeout Errors in Ego-Browser Automation Scripts

> Debug CDP timeout errors in ego-browser automation. Learn to resolve stalled channels, closed tabs, and stale session IDs with expert tips.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: how-to-guide
- Published: 2026-08-06

---

**Ego-browser automation scripts fail with "CDP request timed out" when Chromium's DevTools Protocol channel stalls, the target tab closes, or a stale session ID is used; resolve by inspecting session health, enabling runtime logging, and implementing targeted retry logic.**

Ego-browser automates Chromium through the Chrome DevTools Protocol (CDP), wrapping every call in a runtime layer that adds **15-second timeouts**, session management, and error recovery. When you encounter "CDP request timed out" failures, the root cause usually lies in this transport layer—either the browser process is unresponsive, the target session vanished, or your script is using an expired session ID. This guide walks through the exact debugging workflow using the internal APIs exposed in [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts).

## How CDP Calls Flow Through the Runtime

Understanding the request path is essential for tracing where timeouts originate.

### The Raw CDP Transport

The `rawCdp()` function in [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts) (lines 55-59) builds JSON payloads, registers pending promises, and sends messages via `globalThis.ego.sendCDPMessage`. A timer with **`RESPONSE_TIMEOUT_MS = 15000`** rejects the promise if no response arrives:

```typescript
// browser-runtime.ts#L55-L59 — simplified structure
function rawCdp(method: string, params: object): Promise<any> {
  const id = messageId++;
  const promise = createPendingPromise(id);
  startTimeoutTimer(id, RESPONSE_TIMEOUT_MS); // 15s default
  globalThis.ego.sendCDPMessage({ id, method, params });
  return promise;
}

```

### Session-Aware Wrapper

`browserCdp()` (lines 78-104) adds `ensureSession()` injection and a retry path for lost sessions. It calls `rawCdp()` and catches timeout errors, attempting recovery for session-related failures.

### Session Lifecycle

`ensureSession()` (lines 107-144) creates or re-attaches CDP sessions, enables `Page` events, and caches sessions for **2 seconds** (`SESSION_TTL_MS`). If a session is lost, `invalidateSession()` clears caches. Stale cache entries are a common source of sporadic timeouts.

## Where Timeout Errors Surface

Driver helpers forward raw errors with specific detection functions:

| Driver | CDP Method | Timeout Detector | Source Location |
|--------|-----------|------------------|-----------------|
| **Pointer** (mouse actions) | `Input.dispatchMouseEvent` | `isInputDispatchTimeout(error)` | [`pointer.ts`](https://github.com/citrolabs/ego-lite/blob/main/pointer.ts) lines 97-100 |
| **Keyboard** (key presses) | `Input.dispatchKeyEvent` | `isKeyboardDispatchTimeout(error)` | [`keyboard.ts`](https://github.com/citrolabs/ego-lite/blob/main/keyboard.ts) lines ~660-663 |

These detectors let you distinguish CDP timeouts from other failures like "selector not found."

## Common Causes of CDP Timeouts

| Cause | Symptoms | Mechanism |
|-------|----------|-----------|
| **Target tab closed/detached** | Immediate timeout followed by "Session not found" logs | Response never arrives; fallback retry only works for non-browser-level methods |
| **Heavy page load / long script** | Timeout after full 15s despite open tab | CDP channel blocked by browser main thread |
| **Stale session ID (post-TTL)** | Sporadic timeouts after navigation or short waits | `SESSION_TTL_MS` (2s) expired; manual session ID passing bypasses `ensureSession()` |
| **Browser-level commands** | Timeouts on `Target.*` methods | `browserCdp()` skips session injection, leaving only raw timeout protection |

## Step-by-Step Debugging Workflow

### 1. Identify the Failing Method

Read the error message carefully. `CDP request timed out: Input.dispatchMouseEvent` indicates the pointer driver invoked the call. This determines which timeout detector to use in your retry logic.

### 2. Verify Session Health

Log session state before critical operations:

```typescript
import { ensureSession, state } from "./state.js";

async function debugSession() {
  await ensureSession();
  console.log({
    sessionId: state.sessionId,
    createdAt: new Date(state.sessionAt).toISOString(),
    ttlRemaining: 2000 - (Date.now() - state.sessionAt) // SESSION_TTL_MS = 2000
  });
}

```

Check [`state.ts`](https://github.com/citrolabs/ego-lite/blob/main/state.ts) for the full structure of stored timestamps and session metadata.

### 3. Adjust Timeout Durations

Locate driver-specific constants like `INPUT_DISPATCH_TIMEOUT_MS`. Increase cautiously—higher values mask slow pages but may hide real problems:

```typescript
// Example: overriding pointer timeout (check driver implementation)
import { INPUT_DISPATCH_TIMEOUT_MS } from "./driver/pointer.js";
// INPUT_DISPATCH_TIMEOUT_MS = 30000; // if mutable, or patch source

```

### 4. Enable Runtime Logging

Set `EGO_DEBUG=runtime` or use the `--doctor` CLI flag to surface `handleSendError` invocations and pending request dumps:

```bash
EGO_DEBUG=runtime node dist/out/index.js your-script.js

```

This activates logging at [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts) lines 218-231, showing when requests are cleared and errors propagate.

### 5. Check for Event Backpressure

Call `drainBrowserEvents()` (lines 64-68) to inspect if events are backing up, indicating downstream processing bottlenecks:

```typescript
import { drainBrowserEvents } from "./browser-runtime.js";

const buffered = await drainBrowserEvents();
console.log(`Drained ${buffered.length} events`);

```

### 6. Implement Targeted Retry Logic

Wrap calls using the appropriate timeout detector:

```typescript
import { browserCdp, isInputDispatchTimeout } from "./browser-runtime.js";

async function safeMouseClick(x: number, y: number, attempt = 1): Promise<void> {
  try {
    await browserCdp("Input.dispatchMouseEvent", {
      type: "mousePressed",
      x, y, button: "left", clickCount: 1
    });
  } catch (err) {
    if (isInputDispatchTimeout(err) && attempt < 3) {
      await new Promise(r => setTimeout(r, 500 * attempt));
      return safeMouseClick(x, y, attempt + 1);
    }
    throw err;
  }
}

```

For keyboard actions, substitute `isKeyboardDispatchTimeout()` from [`keyboard.ts`](https://github.com/citrolabs/ego-lite/blob/main/keyboard.ts).

### 7. Validate DOM Attachment

Ensure selectors remain attached before interaction. A detached element causes CDP calls to target a dead session:

```typescript
await page.waitForSelector("[data-test='submit']", { state: "visible" });
await safeMouseClick(100, 200);

```

### 8. Use the E2E Test Harness

Reproduce failures in `taskspace-e2e.test.mjs` for consistent logging and debugging:

```bash
npm test -- src/taskspace-e2e.test.mjs --grep "navigation flow"

```

## Advanced Diagnostic: Detecting Host-Level Failures

If timeouts persist through all above steps, check for catastrophic browser or transport failure. When `handleSendError` rejects **all pending promises simultaneously**, look for log messages containing "task inactive" or "host gone"—these indicate failures outside the JavaScript layer in `globalThis.ego.sendCDPMessage`.

```typescript
// browser-runtime.ts#L218-L231 — handleSendError clears all pending
function handleSendError(error: Error) {
  for (const [id, { reject }] of pendingRequests) {
    reject(error); // Mass rejection indicator
  }
  pendingRequests.clear();
}

```

## Code Reference: Keyboard Action with Full Diagnostics

```typescript
import { ensureSession, state } from "./state.js";
import { browserCdp, isKeyboardDispatchTimeout, drainBrowserEvents } from "./browser-runtime.js";

async function typeDiagnostic(text: string): Promise<void> {
  // Pre-flight checks
  const session = await ensureSession();
  const events = await drainBrowserEvents();
  
  console.log("[DIAG] Session:", session);
  console.log("[DIAG] Session age:", Date.now() - state.sessionAt, "ms");
  console.log("[DIAG] Buffered events:", events.length);
  
  for (const char of text) {
    try {
      await browserCdp("Input.dispatchKeyEvent", {
        type: "keyDown",
        text: char
      });
      await browserCdp("Input.dispatchKeyEvent", {
        type: "keyUp",
        text: char
      });
    } catch (e) {
      if (isKeyboardDispatchTimeout(e)) {
        console.error("[DIAG] Keyboard timeout at char:", char);
        console.error("[DIAG] Current session state:", {
          id: state.sessionId,
          age: Date.now() - state.sessionAt
        });
        throw e;
      }
    }
  }
}

```

## Summary

- **CDP timeouts in ego-browser** originate from `rawCdp()`'s 15-second timer in [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts)
- **Session staleness** (beyond 2-second `SESSION_TTL_MS`) causes sporadic failures—always verify `state.sessionAt`
- **Use timeout detectors** (`isInputDispatchTimeout`, `isKeyboardDispatchTimeout`) to implement targeted retries
- **Enable `EGO_DEBUG=runtime`** to expose internal error handling and pending request state
- **Mass promise rejections** from `handleSendError` signal host-level failures requiring infrastructure investigation

## Frequently Asked Questions

### What is the default CDP timeout in ego-browser?

The default timeout is **15,000 milliseconds** (15 seconds), defined as `RESPONSE_TIMEOUT_MS` in [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts). This applies to all CDP calls through `rawCdp()` unless overridden by driver-specific constants like `INPUT_DISPATCH_TIMEOUT_MS`.

### How do I distinguish a CDP timeout from a selector timeout?

Check the error message and use type guards: **CDP timeouts** contain "CDP request timed out" and can be detected with `isInputDispatchTimeout()` or `isKeyboardDispatchTimeout()`. **Selector timeouts** typically throw from `waitForSelector()` with "waiting for selector" messages and do not trigger the CDP-specific detectors.

### Why do I get timeouts after page navigation?

Navigation invalidates cached sessions, but `SESSION_TTL_MS` (2 seconds) may retain stale references. Call `ensureSession()` explicitly after navigation, or check `state.sessionAt` to confirm freshness. Sporadic post-navigation timeouts almost always indicate TTL expiration.

### Can I disable CDP timeouts entirely?

No—the timeout is hardcoded in `rawCdp()` at line 59 of [`browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/browser-runtime.ts). However, you can increase driver-specific timeouts or wrap calls in retry loops that catch timeout errors and re-execute. For debugging only, patch `RESPONSE_TIMEOUT_MS` in a forked runtime.