Browser Evaluate CDP Refactor Plan
Context
act:evaluate는 페이지 안에서 사용자가 제공한 JavaScript를 실행합니다. 현재는 Playwright(page.evaluate 또는 locator.evaluate)를 통해 실행됩니다. Playwright는 페이지별로 CDP command를 직렬화하므로, 멈추거나 오래 걸리는 evaluate 하나가 해당 페이지의 command queue 전체를 막아 이후 action이 모두 “멈춘 것처럼” 보이게 할 수 있습니다.
PR #13498은 실용적인 안전망(범위 제한 evaluate, abort propagation, best-effort recovery)을 추가했습니다. 이 문서는 act:evaluate를 Playwright에서 본질적으로 분리해, 멈춘 evaluate가 일반 Playwright operation을 끼워 막을 수 없도록 만드는 더 큰 리팩터링을 설명합니다.
Goals
act:evaluate가 같은 탭의 이후 browser action을 영구적으로 막지 않아야 한다- caller가 budget을 신뢰할 수 있도록 timeout이 end-to-end 단일 source of truth여야 한다
- abort와 timeout이 HTTP와 in-process dispatch 전반에서 동일하게 취급되어야 한다
- Playwright 전체를 버리지 않고도 evaluate에 대한 element targeting을 지원해야 한다
- 기존 caller와 payload에 대한 backward compatibility 유지
Non-goals
- 모든 browser action(click, type, wait 등)을 CDP 구현으로 대체하지 않는다
- PR #13498에서 도입한 기존 safety net을 제거하지 않는다(유용한 fallback으로 유지)
- 기존
browser.evaluateEnabledgate를 넘는 새로운 unsafe capability를 추가하지 않는다 - evaluate에 대한 process isolation(worker process/thread)을 추가하지 않는다. 이 리팩터링 이후에도 복구가 어려운 stuck state가 남으면 그건 후속 아이디어다
Current Architecture (Why It Gets Stuck)
현재의 높은 수준 구조:- caller가
act:evaluate를 browser control service로 보낸다 - route handler가 Playwright를 호출해 JavaScript를 실행한다
- Playwright는 페이지 command를 직렬화하므로 끝나지 않는 evaluate가 queue를 막는다
- queue가 막히면 이후 click/type/wait도 해당 탭에서 멈춘 것처럼 보인다
Proposed Architecture
1. Deadline Propagation
단일 budget 개념을 도입하고, 모든 timeout을 여기서 파생시킵니다.- caller가
timeoutMs(또는 미래의 deadline)를 설정 - outer request timeout, route handler logic, 페이지 내부 실행 budget이 모두 같은 budget을 사용하고, 필요한 serialization overhead용 작은 여유만 둠
- abort는 어디서나
AbortSignal로 전파되어 cancellation 동작을 일관되게 만듦
createBudget({ timeoutMs, signal })같은 작은 helper를 추가해 다음을 반환signal: 연결된 AbortSignaldeadlineAtMs: 절대 deadlineremainingMs(): child operation에 넘길 남은 budget
- 이 helper를 다음에 사용
src/browser/client-fetch.ts(HTTP + in-process dispatch)src/node-host/runner.ts(proxy path)- browser action 구현(Playwright + CDP)
2. Separate Evaluate Engine (CDP Path)
Playwright의 페이지별 command queue를 공유하지 않는 CDP 기반 evaluate 구현을 추가합니다. 핵심 속성은 evaluate transport가 별도 WebSocket connection과 별도 target-attached CDP session을 사용한다는 점입니다. 구현 방향:- 새 모듈(예:
src/browser/cdp-evaluate.ts)을 추가하여:- 설정된 browser-level CDP endpoint에 연결
Target.attachToTarget({ targetId, flatten: true })를 사용해sessionId획득- 다음 중 하나 실행:
- 페이지 단위 evaluate는
Runtime.evaluate - element evaluate는
DOM.resolveNode+Runtime.callFunctionOn
- 페이지 단위 evaluate는
- timeout 또는 abort 시:
- 해당 session에 best-effort
Runtime.terminateExecution전송 - WebSocket을 닫고 명확한 오류 반환
- 해당 session에 best-effort
- 이것도 페이지 안에서 JavaScript를 실행하므로 termination은 부작용을 가질 수 있습니다. 다만 Playwright queue를 막지 않고, CDP session을 끊어 transport 차원에서 취소 가능하다는 것이 핵심 이점입니다.
3. Ref Story (Element Targeting Without A Full Rewrite)
어려운 부분은 element targeting입니다. CDP는 DOM handle 또는backendDOMNodeId가 필요하지만, 현재 대부분의 browser action은 snapshot에서 나온 ref 기반 Playwright locator를 사용합니다.
권장 접근: 기존 ref는 유지하되, 선택적으로 CDP가 해석 가능한 id를 추가합니다.
3.1 Extend Stored Ref Info
저장된 role ref metadata에 선택적으로 CDP id를 포함하도록 확장:- 현재:
{ role, name, nth } - 제안:
{ role, name, nth, backendDOMNodeId?: number }
backendDOMNodeId가 있을 때 CDP evaluate도 같은 ref 값을 사용할 수 있습니다.
3.2 Populate backendDOMNodeId At Snapshot Time
role snapshot 생성 시:- 지금처럼 기존 role ref map(role, name, nth) 생성
- CDP의 AX tree(
Accessibility.getFullAXTree)를 가져와 같은 duplicate handling rule을 사용해(role, name, nth) -> backendDOMNodeId병렬 map 계산 - 현재 탭의 저장된 ref info에 해당 id를 병합
backendDOMNodeId는 undefined로 둡니다. 이렇게 하면 안전한 best-effort rollout이 가능합니다.
3.3 Evaluate Behavior With Ref
act:evaluate에서:
ref가 있고backendDOMNodeId가 있으면 CDP element evaluate 사용ref는 있지만backendDOMNodeId가 없으면 Playwright path로 fallback(안전망 유지)
- 고급 caller나 디버깅용으로 request shape에
backendDOMNodeId를 직접 받도록 확장할 수 있으나, 기본 인터페이스는 여전히ref를 유지
4. Keep A Last Resort Recovery Path
CDP evaluate를 도입해도 탭이나 connection이 막히는 다른 경로는 여전히 존재합니다. 따라서 다음 용도로 기존 recovery mechanism(terminate execution + disconnect Playwright)은 마지막 수단으로 유지합니다.- legacy caller
- CDP attach가 차단된 환경
- 예상치 못한 Playwright edge case
Implementation Plan (Single Iteration)
Deliverables
- Playwright의 per-page command queue 밖에서 동작하는 CDP 기반 evaluate engine
- caller와 handler가 일관되게 사용하는 단일 end-to-end timeout/abort budget
- element evaluate를 위해 선택적으로
backendDOMNodeId를 담을 수 있는 ref metadata - 가능하면
act:evaluate는 CDP engine을 우선하고, 아니면 Playwright로 fallback - stuck evaluate가 이후 action을 막지 않는다는 것을 증명하는 test
- failure와 fallback을 가시화하는 log/metric
Implementation Checklist
timeoutMs+ upstreamAbortSignal을 다음으로 묶는 공유 “budget” helper 추가:- 단일
AbortSignal - 절대 deadline
- downstream operation용
remainingMs()helper
- 단일
- 모든 caller path를 이 helper로 갱신해
timeoutMs의 의미를 통일:src/browser/client-fetch.ts(HTTP + in-process dispatch)src/node-host/runner.ts(node proxy path)/act를 호출하는 CLI wrapper (browser evaluate에--timeout-ms추가)
src/browser/cdp-evaluate.ts구현:- browser-level CDP socket 연결
Target.attachToTarget로sessionId획득- page evaluate에는
Runtime.evaluate - element evaluate에는
DOM.resolveNode+Runtime.callFunctionOn - timeout/abort 시 best-effort
Runtime.terminateExecution후 socket close
- 저장된 role ref metadata에
backendDOMNodeId를 선택적으로 추가:- 기존
{ role, name, nth }동작은 Playwright action용으로 유지 - CDP element targeting용
backendDOMNodeId?: number추가
- 기존
- snapshot 생성 시
backendDOMNodeId를 best-effort로 채움:- CDP에서 AX tree(
Accessibility.getFullAXTree) 조회 (role, name, nth) -> backendDOMNodeId계산 후 저장된 ref map에 병합- mapping이 애매하거나 실패하면 id는 undefined로 둠
- CDP에서 AX tree(
act:evaluaterouting 갱신:ref가 없으면 항상 CDP evaluate 사용ref가backendDOMNodeId로 해석되면 CDP element evaluate 사용- 그 외에는 Playwright evaluate로 fallback(여전히 bounded + recoverable)
- 기존 “last resort” recovery path는 기본 경로가 아니라 fallback으로 유지
- test 추가:
- 의도적으로 멈춘 evaluate는 budget 안에서 timeout되고 이후 click/type는 성공
- abort는 evaluate를 취소(client disconnect 또는 timeout)하고 subsequent action을 unblock
- mapping 실패 시 Playwright로 깨끗하게 fallback
- observability 추가:
- evaluate duration, timeout counter
- terminateExecution 사용 횟수
- fallback rate(CDP -> Playwright)와 이유
Acceptance Criteria
- 의도적으로 멈춘
act:evaluate는 caller budget 안에서 반환되고, 이후 tab action을 막지 않아야 한다 timeoutMs는 CLI, agent tool, node proxy, in-process call 전반에서 일관되게 동작해야 한다ref를backendDOMNodeId로 매핑할 수 있으면 CDP를 사용하고, 그렇지 않으면 fallback path도 여전히 bounded/recoverable 해야 한다
Testing Plan
- Unit test:
- role ref와 AX tree node 사이의
(role, name, nth)matching logic - budget helper 동작(headroom, remaining time 계산)
- role ref와 AX tree node 사이의
- Integration test:
- CDP evaluate timeout이 budget 안에서 반환되고 이후 action을 막지 않음
- abort가 evaluate를 취소하고 best-effort termination을 트리거
- Contract test:
BrowserActRequest,BrowserActResponse가 계속 호환되는지 확인
Risks And Mitigations
- mapping이 불완전할 수 있음
- 완화: best-effort mapping, Playwright evaluate fallback, debug tooling 추가
Runtime.terminateExecution은 부작용이 있을 수 있음- 완화: timeout/abort 시에만 사용하고 오류 메시지에 동작을 문서화
- 추가 overhead
- 완화: snapshot이 필요할 때만 AX tree 조회, target별 cache 사용, CDP session은 짧게 유지
- extension relay 제한
- 완화: per-page socket이 없을 때 browser-level attach API 사용, 기존 Playwright path는 fallback으로 유지
Open Questions
- 새 engine을
playwright,cdp,auto로 설정 가능하게 할 것인가? - 고급 사용자용 새
nodeRef형식을 노출할 것인가, 아니면ref만 유지할 것인가? - frame snapshot과 selector-scoped snapshot은 AX mapping에 어떻게 참여해야 하는가?