Skip to content

[fix]: clean up cdp session event handlers#2288

Open
seanmcguire12 wants to merge 8 commits into
mainfrom
seanmcguire/stg-2415-clean-up-session-handlers-on-target-detach
Open

[fix]: clean up cdp session event handlers#2288
seanmcguire12 wants to merge 8 commits into
mainfrom
seanmcguire/stg-2415-clean-up-session-handlers-on-target-detach

Conversation

@seanmcguire12

@seanmcguire12 seanmcguire12 commented Jun 29, 2026

Copy link
Copy Markdown
Member

why

CdpSession.on(...) stores session scoped listeners on the root CdpConnection under keys like ${sessionId}:${event}. when chrome sends Target.detachedFromTarget, CdpConnection removes session metadata, but the actual event handlers were not being removed

this was problematic because stale handler functions remained reachable for the lifetime of the root connection. this could cause memory to grow, particularly for long sessions with a lot of attach/detach churn

what changed

added CdpConnection.clearSessionEventHandlers(sessionId), which:

  • clears session scoped handlers when Target.detachedFromTarget or Target.targetDestroyed fires

test plan

added tests in packages/core/dist/esm/tests/unit/cdp-connection-close.test.js, which verify:

  • detached session handlers are removed
  • multiple handlers for the same detached session are removed
  • handlers for other live sessions are preserved
  • root-level handlers are preserved
  • stale session handlers are removed when Target.targetDestroyed fires

Summary by cubic

Fixes memory leaks and prevents hangs by cleaning up CDP session event handlers and rejecting pending work when targets detach or are destroyed. Aligns with Linear STG-2415.

  • Bug Fixes
    • Added clearSessionEventHandlers(sessionId) to remove ${sessionId}:* listeners.
    • Added rejectSessionPendingWork(sessionId, targetId) to reject in-flight .send calls and session dispatch waiters with PageNotFoundError.
    • On Target.detachedFromTarget and Target.targetDestroyed: call both methods and delete affected sessions/mappings (for destroyed targets, all sessions mapped to the target). Tests cover cleanup across detach/destroy, multiple sessions per target, rejection of pending work, and preservation of other sessions and root-level handlers.

Written for commit 5e7d04a. Summary will update on new commits.

Review in cubic

@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e7d04a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@browserbasehq/stagehand Patch
@browserbasehq/stagehand-evals Patch
@browserbasehq/stagehand-server-v3 Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files

Confidence score: 3/5

  • In packages/core/lib/v3/understudy/cdp.ts, the Target.targetDestroyed cleanup currently exits after the first matching session, so other session-bound handlers on the same target can remain attached and keep receiving events unexpectedly; this can cause stale callbacks/memory leaks and cross-session side effects after a target closes — update the cleanup to continue iterating and remove handlers for all sessions tied to that target before merging.
Architecture diagram
sequenceDiagram
    participant Chrome as Chrome DevTools (CDP)
    participant Connection as CdpConnection
    participant Handlers as eventHandlers Map
    participant Sessions as sessions Map
    participant GlobalHandler as Root event handler
    participant OtherSession as CdpSession (session-b)

    Note over Chrome,OtherSession: Session attach and handler registration

    Chrome->>Connection: Target.attachedToTarget (sessionId: "session-a")
    Connection->>Sessions: store session metadata
    Connection-->>Chrome: ack

    Connection->>Connection: session.on("Fetch.requestPaused", handler)
    Connection->>Handlers: set key "session-a:Fetch.requestPaused"
    Connection->>Connection: session.on("Network.requestWillBeSent", handler)
    Connection->>Handlers: set key "session-a:Network.requestWillBeSent"

    Note over Chrome,OtherSession: Another session attaches (to verify preservation)

    Chrome->>Connection: Target.attachedToTarget (sessionId: "session-b")
    Connection->>Sessions: store session-b metadata
    Connection->>Connection: otherSession.on("Fetch.requestPaused", handler)
    Connection->>Handlers: set key "session-b:Fetch.requestPaused"

    GlobalHandler->>Connection: on("Target.targetCreated", rootHandler)
    Connection->>Handlers: set key "Target.targetCreated" (root-level, no prefix)

    Note over Chrome,GlobalHandler: --- Detach triggers cleanup ---

    Chrome->>Connection: Target.detachedFromTarget (sessionId: "session-a", targetId: "target-a")
    Connection->>Connection: NEW: clearSessionEventHandlers("session-a")
    Connection->>Handlers: delete keys starting with "session-a:"
    Handlers-->>Connection: removed "session-a:Fetch.requestPaused" & "session-a:Network.requestWillBeSent"
    Connection->>Sessions: delete session-a metadata
    Connection-->>Chrome: ack

    Chrome->>Connection: Target.targetCreated (target-b)   [root event]
    Connection->>Handlers: lookup "Target.targetCreated"
    Handlers-->>Connection: rootHandler
    Connection->>GlobalHandler: invoke root handler
    GlobalHandler-->>Connection: done

    Note over Connection,Handlers: Verify other session handlers remain
    alt Other handler preserved
        Connection->>Handlers: check key "session-b:Fetch.requestPaused"
        Handlers-->>Connection: still present
    end

    Note over Chrome,GlobalHandler: --- Alternative: Target.targetDestroyed ---

    Chrome->>Connection: Target.targetDestroyed (targetId: "target-b")
    Connection->>Sessions: find sessionId for targetId "target-b"
    Sessions-->>Connection: "session-b"
    Connection->>Connection: NEW: clearSessionEventHandlers("session-b")
    Connection->>Handlers: delete keys starting with "session-b:"
    Handlers-->>Connection: removed "session-b:Fetch.requestPaused"
    Connection->>Sessions: delete session-b metadata
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/lib/v3/understudy/cdp.ts Outdated
@seanmcguire12 seanmcguire12 force-pushed the seanmcguire/stg-2415-clean-up-session-handlers-on-target-detach branch from 4ceadba to 1d7e608 Compare July 1, 2026 20:13
@seanmcguire12

Copy link
Copy Markdown
Member Author

@cubic-dev-ai

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai

@seanmcguire12 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files

Confidence score: 3/5

  • In packages/core/lib/v3/understudy/cdp.ts, the Target.targetDestroyed cleanup path for mapped sessions can drop session state without rejecting pending send/dispatch waiters, which can leave CDP calls hanging and cause user-visible stalls or timeouts. Mirror the teardown behavior used in the T... branch so pending promises are explicitly rejected before removing handlers, then add a regression test for destroyed-session in-flight requests before merging.
Architecture diagram
sequenceDiagram
    participant Chrome as CDP Agent
    participant Conn as CdpConnection
    participant Handlers as eventHandlers Map
    participant Sessions as sessions/maps

    Note over Chrome,Sessions: Target.detachedFromTarget Flow

    Chrome->>Conn: "Target.detachedFromTarget" (sessionId, targetId)
    Conn->>Conn: Extract sessionId
    Conn->>Conn: NEW: clearSessionEventHandlers(sessionId)
    Conn->>Handlers: Iterate keys starting with `${sessionId}:`
    Handlers-->>Conn: Delete all matching entries
    Conn->>Sessions: Remove sessionId from sessions map
    Conn->>Sessions: Remove sessionId from sessionToTarget map
    Conn->>Sessions: Remove sessionId from latestCdpCallEvent map
    Conn-->>Chrome: (event processed)

    Note over Chrome,Sessions: Target.targetDestroyed Flow

    Chrome->>Conn: "Target.targetDestroyed" (targetId)
    Conn->>Conn: Find all sessionIds for targetId
    loop For each sessionId mapped to this target
        Conn->>Conn: NEW: clearSessionEventHandlers(sessionId)
        Conn->>Handlers: Delete `${sessionId}:*` keys
        Conn->>Sessions: Delete sessionId from sessions map
        Conn->>Sessions: Delete sessionId from sessionToTarget map
        Conn->>Sessions: Delete sessionId from latestCdpCallEvent map
    end
    Conn-->>Chrome: (event processed)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/lib/v3/understudy/cdp.ts
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.

1 participant