fix(webdriver): give cloud session creation its own budget and stop leaking billed sessions - #1782
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Cancellation cleanup reports a session as released even when WebDriver DELETE fails. |
|
Re-review at |
|
Addressed in Release evidence was success-shaped even when DELETE failed — fixed at the root, not patched. The reason Release evidence is now claimed only on a clean release (no warnings, no throw). A failed DELETE yields Side effect: PR body rewritten to describe the reviewed head as a whole (43 files, +1052/−205, 6 commits) instead of a per-commit history. Smoke |
|
Re-review at One cleanup remains: Readiness still requires rerunning/classifying red CI. iOS Smoke and Swift Runner Unit Compile failed while downloading |
|
Re-review at The previously noted cleanup is also still present: Exact-head readiness remains blocked pending rerun/classification: Swift Runner, FreeRange, and CodeQL JS failed while downloading actions (codeload 429/503); Coverage ended in a worker/unexplained crash. iOS Smoke is still pending. No label. |
Code-quality review (strict pass, head
|
|
All five addressed at
Also from the 16:23 review: the deterministic virtual-clock test ( CI on |
|
Re-review at |
|
You're right — in unifying the release path I traded one ambiguity for another and then blessed it in the test. Fixed at the source in
PR body corrected to match (the "failed DELETE yields |
|
Clean re-review at |
|
Classified and retriggered. CodeQL run
|
|
Fresh exact-head CI on 0029940 exposed one actionable missed assertion: |
…eaking billed sessions Cloud lease allocation ran under the generic 30s/1-retry request policy, so BrowserStack iOS real-device session creation (45-90s) aborted client-side at ~60s on most runs. Each timed-out POST /session still completed server-side and, being non-idempotent, was retried — leaving two billed provider sessions per failed open with no id to release them. - POST /session is its own phase: a 180s create budget (default), zero retries, and no request-bound abort, so the daemon always learns the session id. - lease_allocate carries a 300s allocation budget surfaced to providers as LeaseLifecycleContext.deadline, and a matching 330s client envelope that preserves the daemon on timeout (a reset would SIGKILL mid-create and orphan every billed session the daemon held). - The request's cancellation signal is ownership evidence: a session that completes after the requester left is released, not registered; a create that the transport gives up on surfaces typed evidence (provider + lease) so an operator can find and stop the maybe-orphaned session. Closes #1774
Review pass over the session-create fix:
- The canceled-request error had nine hand-rolled copies (src/request/cancel,
maestro shared, exec, retry, install-source x2, and the new provider one).
It now has one definition in @agent-device/kernel/errors:
createRequestCanceledError(details?, cause?) + isRequestCanceledError +
REQUEST_CANCELED_REASON. Callers add evidence or a sharper hint; the reason
itself is not overridable, so nothing can build one the predicate misses.
- lease_allocate's timeout bundle moves beside INSTALL_TIMEOUT_POLICY in the
registry (same {...DEFAULT, envelopeMs, onTimeout} shape); the request timeout
constant stays exported from timeout-policy like its siblings.
- Transport: fetch helper returns Response's own ok/status; the timeout reason
const is private behind isWebDriverRequestTimeout.
- Client: one-use options type inlined; the two deadline helpers share one floor.
- Session-manager tests: shared makeRuntime/jsonResponse/afterEach restore.
Net -29 lines with the feature in.
…rough startup Second-order follow-ups the #1774 refactor made cheap: - markRequestCanceled aborts the request signal WITH the kernel's typed canceled error as its reason. Every signal.throwIfAborted(), aborted fetch, and 'throw signal.reason' in the daemon (20+ sites) now surfaces a canceled request as such instead of a bare DOMException that normalized to UNKNOWN — and no site has to know the factory exists. - AWS Device Farm prepareSession owns the remote-access ARN from the moment create-remote-access-session answers: a startup timeout, the allocation deadline, or a canceled request now stops it before the failure surfaces (previously a timed-out startup left a RUNNING billed session behind — the same leak class as the WebDriver session, one phase earlier). The startup wait is capped by LeaseLifecycleContext.deadline and wakes on cancellation. - BrowserStack's pre-session local app upload honors the request signal (an upload is not billed, so plain abort is right there). - lease_heartbeat/lease_release share lease_allocate's preserve-daemon policy: the rationale — the daemon owns billed sessions; a reset orphans them all — applies verbatim. Each AWS ownership test proven red without the guard (3/3).
Shrink pass — same behavior, less duplication: - releaseOnFailure(primaryError, release) in webdriver-utils replaces the two identical 'best-effort stop the billed resource, attach cleanupError to the primary AppError' helpers (WebDriver session + AWS remote-access ARN); shared errorMessage too. - The lease handler pulls the request signal from getRequestSignal(requestId) like every sibling handler, instead of threading a requestSignal arg through LeaseHandlerArgs and the request-handler chain. Drops the field, the wiring, and five mechanical test edits; the handler test now proves the request-bound signal (abort it, watch the provider's signal flip) rather than arg identity. - Inlined the one-use requestHeaders back into fetchWebDriver. Handler-signal test proven red without the wiring.
…r; honest release evidence Review follow-up. The provider was doing the daemon's job: it treated the request signal as 'ownership evidence, not an interrupt' and needed three paragraphs to say so. The daemon owns the request, so it now decides — generically, for every provider — what happens to a lease that finished allocating after its requester left: release it (provider + registry) and answer with the canceled error. - lease.ts: after allocate returns, isRequestCanceled(requestId) → releaseAllocationForGoneRequester(). Release evidence is claimed ONLY on a clean release (no warnings, no throw); a WEBDRIVER_SESSION_DELETE_FAILED release is reported released:false with providerSessionId + a stop-by-hand hint (thymikee's finding: the previous evidence was success-shaped even when DELETE failed). - WebDriverSessionManager: the createOwnedSession/releaseCanceledSession trio is gone; allocate is plain 'create with a budget; on failure clean up' again. - LeaseLifecycleContext.signal is just cancellation, like everywhere else; the ownership-semantics comments on the contract, client, registry, AWS prepare and utils shrink to what the code no longer says itself. - Tests: the two provider-level cancellation tests move to the daemon handler (where the logic now lives), plus the failing-DELETE regression; both proven red without the post-allocate check.
…he 120s default Live iOS real-device run: startup needed ~128s and hit the standalone 120s default while the daemon's 300s allocation budget still had room — the new ownership guard correctly stopped the ARN, but the open failed for no reason. When the daemon supplies a deadline it is the bound; the default only applies standalone. Rerun: open in 112s, snapshot, clean close, session STOPPING.
… default; drop empty import Review follow-ups on 7f9d148: a virtual-clock test (Date.now advanced 10s per poll, RUNNING at 150s, deadline 300s) that fails on the old min(default, deadline) logic and passes now; and the empty 'import {} from kernel/errors' left in maestro/shared.ts is removed.
…AWS on releaseOnFailure Code-quality review at 7f9d148: 1. aws-device-farm.ts still carried its own copy of releaseOnFailure (the dedupe commit's script aborted before reaching it and I mis-verified). Now uses the shared helper; private copy deleted. 2. Empty 'import {} from kernel/errors' in maestro/shared.ts removed (2738700). 3. errorMessage() lives in @agent-device/kernel/errors; the two copies this PR had added (lease.ts, webdriver-utils.ts) import it. Sweeping the pre-existing copies is a follow-up. 4. lease.ts has ONE release path: releaseLease(registry, provider, lease, request, ctx) → { released (registry), provider } used by both the lease_release case (wire shape unchanged) and the gone-requester branch, which folds a throwing provider release into releaseError. 'released' now means the same thing in both; the provider verdict is a separate 'providerReleased' (warnings-free, no throw) that drives the stop-by-hand hint. -~35 lines. 5. sessionCreateTimeoutMs is Omit-ed at the WebDriverTransportOptions boundary instead of Pick-ed back out internally.
…ion is confirmed gone Re-review at 3665ea0: unifying the release path had made the cancellation error report released:true from the daemon's registry record while the provider DELETE had failed — success-shaped again, with the operator verdict demoted to a second key. Fixed at the source of the ambiguity: - LeaseReleaseOutcome names its bookkeeping field registryReleased. - On the canceled error, 'released' is true only when registryReleased AND the provider released without warnings AND without throwing; the registry record is exposed as 'registryReleased'. The stop-by-hand hint keys on 'released'. - lease_release keeps its existing wire field ('released' = registry; provider cleanup rides in 'provider'), unchanged. - Regressions: failed DELETE and throwing release both pin released:false / registryReleased:true (+ providerSessionId, warnings|releaseError, hint); both proven red on registry-only semantics.
Run 32051017472 is wedged on GitHub's side: status=completed with Analyze (python) still queued and Analyze (java-kotlin) failed only at SARIF upload (503, 'No server is currently available'). It can be neither cancelled nor rerun, and default-setup CodeQL has no dispatchable workflow, so a new push is the only way to get a fresh run. No source change.
…et probe main's #1790 tightened this test to expect the raw TimeoutError DOMException, which this PR intentionally normalizes into AppError{reason: webdriver_request_timeout}. On the merge ref the two met and Coverage went red. The regression now asserts the structured contract and that the second request's budget is the shared remainder (~118ms of 200 after an 80ms first call).
0029940 to
ba79c23
Compare
|
Fixed — and it's a merge-ref interaction, not a miss in the branch's own tree: main's Rebased onto current main (clean, no conflicts) and updated that regression to the structured contract: |
|
Re-reviewed exact head ba79c23: the prior Coverage blocker is correctly fixed. The activeElement regression now requires the typed webdriver_request_timeout AppError and proves the second request receives only the shared remaining budget; reverting either timeout normalization or shared budgeting makes it fail. No new code or architecture finding, and the existing cloud-device evidence remains applicable. Code review is clean, but final G2G waits for Coverage and the remaining exact-head CI/smoke lanes currently in progress. |
|
Android Smoke on The failure was
iOS ×2 and Web smoke were already green on this head. |
|
iOS Smoke on The failure was
With that, every check on |
|
Summary
Cloud WebDriver session creation ran under the generic per-request policy —
timeoutMs ?? 30_000,retryAttempts ?? 1— andlease_allocatecarried the default 90s/reset-daemon envelope. On BrowserStack iOS real devices (session creation 45–90s)openaborted client-side at ~60s on most runs, and becausePOST /sessionis non-idempotent and completes server-side even after the client aborts, each failedopenleaked two billed provider sessions (the abandoned create + the automatic retry). Closes #1774.What changes (as of the reviewed head)
Session creation is its own phase —
WebDriverClient.createSessionruns under a dedicatedsessionCreateTimeoutMs(default 180s), withretryAttempts: 0and no cancellation signal: a retry is a second billed session, an abort loses the id of the first. An optional operationdeadlinecan only shorten that budget.lease_allocategets a cloud-sized policy — a 300sLEASE_ALLOCATION_BUDGET_MShanded to providers asLeaseLifecycleContext.deadline, a matching 330s client envelope derived from it (so the two can't drift), andpreserve-daemonfor the whole lease route: resetting the daemon on a client timeout would SIGKILL it mid-create/mid-release and orphan every billed session it held.The daemon owns what happens to a lease allocated for a requester that left. After
allocatereturns, if the request was canceled meanwhile (explicit cancel or client disconnect), the lease handler releases it — provider and registry — and answers with the canceled error. This is generic across providers and shares the one release path withlease_release(releaseLease()→{ registryReleased, provider }). On the canceled error,releasedanswers the operator's question — is the billed session gone? — and istrueonly when the provider released without warnings and without throwing; daemon bookkeeping is the separateregistryReleased. A provider that could not delete its session (WEBDRIVER_SESSION_DELETE_FAILED), or whose release threw, is reportedreleased: falsewithproviderSessionId, thewarnings/releaseError, and a stop-it-by-hand hint. (lease_release's existing wire fieldreleasedkeeps its registry meaning; provider cleanup rides inproviderthere, as before.)Provider-side ownership of billed resources on failure — the AWS Device Farm remote-access ARN is stopped on any failure short of RUNNING (startup timeout, allocation deadline, cancellation); previously a startup timeout left it
RUNNINGand billing. A create the transport gives up on raises a typedprovider_session_create_timeouterror naming the lease so an operator can find the maybe-orphaned session, rather than heuristic REST cleanup of a session this process never owned. SharedreleaseOnFailurecovers both cleanups.Typed cancellation, everywhere — the canceled-request error had nine hand-rolled copies; it is now one kernel definition (
createRequestCanceledError/isRequestCanceledError), andmarkRequestCanceledaborts the request signal with that error as its reason, so everysignal.throwIfAborted(), aborted fetch, andthrow signal.reasonin the daemon surfacesrequest_canceledinstead of a bareDOMException→UNKNOWN. The transport's own deadline is likewise a typedwebdriver_request_timeout, distinct from a caller cancel.Also: BrowserStack's pre-session local app upload honors the request signal (an upload isn't billed, so plain abort is right there).
Validation
Every behavioral pin was proven red-then-green (production change reverted locally, test watched fail, restored):
webdriver-transport.test.ts— deadline abort is a typed timeout; caller abort is not reclassified.webdriver-client.test.ts—createSessiondoes not retry a transient create; waits past the 30s per-request default; is bounded by a shorter deadline.runtime-session.test.ts— create-timeout surfaces provider+lease evidence; primary failure survives a failing cleanup.aws-device-farm.test.ts— startup timeout / cancellation / deadline each stop the ARN; RUNNING is handed on unstopped.request-handler-catalog.test.ts— allocation hands the provider the request-bound signal (abort it, watch it flip) and a deadline; a lease allocated for a gone requester is released and not registered; a failed DELETE and a throwing release are each reportedreleased: false/registryReleased: truewith recovery evidence (both proven red on registry-only semantics).cancel.test.ts— factory keeps evidence and never loses its reason; a canceled request's signal carries the typed error.timeout-policy.test.tsreviewed-set pins updated; the integration test that pinned session-create retry was flipped to pin zero-retry.Gates:
pnpm check:affected --rungreen (815 files), typecheck, lint, fallow, layering, daemon-wire-compat (protocol unchanged), fullprovider-integration. The mutation lane reports "nothing to mutate" because its baseline isgating: false(pre-graduation), not a skip.Live cloud validation (real devices, this branch's daemon):
app-release.apk:openin 18s, 65-node snapshot,close,disconnect; REST API shows the sessiondone/CLIENT_STOPPED_SESSIONand zero running sessions afterward. (A first attempt with a wrong package name failed atactivateAppafter a successful create — and was still released cleanly, no leak.) The account has no iOS app uploaded, so the iOS path was validated on AWS instead.openin 112s (would have died at the old 90slease_allocateenvelope), 84-node snapshot,close,disconnect→ sessionSTOPPING. The first attempt exposed that the AWS startup wait still capped at its standalone 120s default while the daemon's 300s budget had room; the new ARN-ownership guard correctly stopped that session (COMPLETED / STOPPED— before this PR it would have satRUNNINGand billing), and7f9d1481amakes the allocation deadline the bound when present.Smoke (
wait_capture_stalled): the runner files in this diff are import-only switches to the kernel canceled-error and no runner code inspects the abort reason; re-running on the current head.Scope
43 files across 10 commits. ~15 files are the fix proper (WebDriver provider package, descriptor timeout policy, lease handler, contract); the rest is the mechanical import switch for the canceled-error consolidation.
LeaseLifecycleContextgained optionalsignal/deadline; other lifecycle implementers (limrun) ignore them. Two fallow clone-group findings inmaestro/shared.tsare pre-existing (they entered scope because a function was deleted from that file) and are excluded by the audit gate as inherited.