05e93a4869
BrowserFetcher.run navigated, waited for "body", read once, and closed the tab - about half a second end to end. The Cloudflare interstitial has a body too, so WaitReady was satisfied by the challenge page itself, and the read that followed was of the interstitial rather than the site. That made the challenge unclearable rather than merely slow. An interstitial needs several seconds of a live page to solve itself and write clearance into the browser's shared cookie jar; tearing the tab down first means the clearance that would have unblocked every later fetch is never obtained, so each call is challenged exactly like the one before it. run now holds one tab and re-reads until the caller's predicate reports an answer, bounded by challengeTimeout and by the caller's own deadline. Each caller supplies the predicate that fits its payload: kagane's in-page fetch simply returns nothing while challenged, whereas novelfull's payload is the DOM, and the interstitial has a DOM as well, so that one excludes the challenge markup explicitly. Exhausting the budget is now reported as errChallengeHeld and mapped back to the 403 the poller already expects, keeping a challenged site distinct from a broken transport. Image loses its own retry loop, which run now subsumes. Measured against a real kagane cover from a cold browser profile: no image at all before, 4.9s to a 56710-byte image/webp after. The live proof is TestSmokeKagane* in smoke_image_test.go, which skips unless SMOKE_BROWSER_WS_URL names a sidecar, so `go test ./...` stays hermetic.