Skip to content

release: promote staging to main — uncapped crawl, streaming batch, one fetch budget, 0.6.17 reconcile - #99

Merged
0xMassi merged 16 commits into
mainfrom
staging
Aug 5, 2026
Merged

release: promote staging to main — uncapped crawl, streaming batch, one fetch budget, 0.6.17 reconcile#99
0xMassi merged 16 commits into
mainfrom
staging

Conversation

@0xMassi

@0xMassi 0xMassi commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Promotes staging to main. Contains PR #98 and PR #97, both merged to staging with CI green.

#98 — uncapped crawl, streaming batch, one fetch budget

  • Crawl without a page limit. CrawlConfig.max_pages is Option<usize>; None crawls until the site is exhausted. --max-pages 0 on the CLI, max_pages: 0 on the MCP tool and self-host server. Defaults unchanged, so a caller who says nothing still gets a bounded crawl.
  • Streaming batch. POST /v1/batch with "stream": true answers NDJSON, one result per line as each URL finishes. Memory is flat regardless of batch size, and the maximum batch size is gone.
  • Crawl streaming mode. stream_only drops each page after reporting it, so peak memory stays flat — this is what makes an unbounded crawl practical.
  • One time budget per fetch. The client applied timeout twice per attempt and the retry loop ran two attempts, so a 12s timeout permitted 49s of wall clock. total_timeout now bounds the whole operation.
  • Response bodies no longer copied twice. 500 KB pages 337 µs → 55 µs, 16 MB 5.3 ms → 1.5 ms, peak memory per in-flight fetch halved.
  • Chronopost parcel tracking extractor.

#97 — 0.6.17 reconcile

The MCP registry has advertised 0.6.17 since 2026-07-22, but no tag was ever cut, so 0.6.16 stayed the newest release while the registry pointed at a version that did not exist. This reconciles the repo and drops a competitor's name from the Chinese README so it matches the English one.

Merge conflict, resolved

CHANGELOG.md conflicted: both sides inserted at the same point in [Unreleased]. #98 added a Performance entry, #97 added the released [0.6.17] section. Both were kept — the Performance note stays under [Unreleased] (not yet released) and [0.6.17] sits below it as a cut version. Verified cargo fmt --check --all clean and 682 lib tests passing on the merged tree before pushing.

Not tagged

Cargo.toml reads 0.6.17, but the tag is deliberately not cut in this PR. Since #98 landed after the 0.6.17 notes were written, the next tag needs to contain it — so the release should be v0.6.18, decided separately.

This matters downstream: webclaw-server PR #31 currently pins core by branch (branch = "feat/uncapped-crawl"), which breaks the tag-pinning rule and cannot ship. It gets repinned once a tag exists.

🤖 Generated with Claude Code

0xMassi and others added 16 commits August 3, 2026 15:20
There was no way to tell whether fetch-path time was network or CPU, so
there was no basis for deciding what to optimise. This measures both
halves separately over a caller-supplied URL list.

Reports p50/p90/p99 for fetch and extract, the extract share of measured
time, and how many HTTP 200s carry under 10 KB of HTML or under 50
extracted words — the shape downstream consumers re-fetch through a
browser, which costs an order of magnitude more than a direct fetch.

The URL list is passed at runtime and never committed: a realistic
corpus comes from real traffic, which is not public data.

benchmarks/latency.md documents how to run it and, more importantly, how
to read it — including that p99 sits at the configured timeout rather
than measuring anything, and that a thin-body trip means opposite things
depending on whether it fired on word count or byte size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client-level `.timeout()` governs getting a response head; it does
not cover streaming the body. The read loop had a size ceiling but no
deadline, so a peer that stopped sending mid-body held the connection
open indefinitely and a fetch could run well past the configured
timeout. Measured on a real corpus: a 12s timeout produced fetches of
22.6s. The size ceiling cannot help — a stalled stream never reaches it.

Bounded as an IDLE window rather than a total-body deadline, and that
distinction is the whole point. A total deadline punishes size instead
of stalling: the first attempt at this killed a healthy 16.5 MB CSV
export outright, and where it survived it did so only by failing and
retrying, which cost more than the original problem. A stalled peer is
distinguishable because it stops delivering chunks, so the clock resets
on every chunk and only silence trips it. That download now completes
normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Latency depends heavily on where the request leaves from, and the
harness could only measure this host's own connection. Adds:

  - BENCH_PROXY_FILE — builds the rotating pool, so pool behaviour
    (per-host client affinity, reuse across the pool) is measured rather
    than assumed. WEBCLAW_PROXY still routes everything through one.
  - BENCH_LABEL — tags every row, so runs across egress paths can be
    concatenated and compared per-URL rather than only in aggregate.
  - success rate and total bytes fetched — metered egress is billed per
    GB, so a run's cost is now visible alongside its latency.

Proxy lists hold credentials and stay outside the repo, same as the URL
corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`error` was set only on transport failure, so a 403 challenge page —
which arrives quickly, carries a body, and raises no error — scored as a
successful fetch. That lets an egress path that gets BLOCKED look faster
than one served the real page.

This was not hypothetical. An A/B of two proxy exits produced an
apparent 28x speedup that was a 403 stub (6.4 KB, 0 words) timed
against a real 200 (445 KB). Under a status + body-size gate the same
comparison is ~2x, and the pooled result across the corpus drops from
"halves latency" to a paired median around 1.5-2.1x.

Latency percentiles now cover 2xx only; non-2xx and transport errors are
counted and reported separately, with the distinct status codes listed
so a suspiciously fast arm is visible rather than flattering.

benchmarks/latency.md gains the two traps this exposed: fast-failure
scoring, and a noise floor measured at 1.8x mean across four runs of an
unchanged config — which is larger than most effects anyone will want to
claim. Includes the design that actually settles such a comparison:
interleaved arms, a null A-vs-A arm, dedup to backends rather than
hostnames, and paired medians instead of aggregate p50 deltas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MCP registry has advertised 0.6.17 since 2026-07-22, but no matching
tag was ever cut, so 0.6.16 stayed the newest release and the registry
pointed at a version that did not exist. Bumps the workspace to 0.6.17
so a tag can be pushed and the two agree.

Also drops a competitor's name from the Chinese README. The registry
manifest was reworded at the same time as the 0.6.17 entry and that text
is live and clean, but README_zh-CN kept the old "self-hostable
<competitor> alternative" phrasing while the English README had already
been changed — so the claim stayed visible on the repo page. Both now
describe webclaw on its own terms.

Note the registry keeps immutable per-version history: the 0.1.6 and
0.6.16 entries still carry the old wording and cannot be edited. Only
the latest entry is served, and that one is already correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to the core HTTP path, all deterministic and unit-verified.
No behaviour change.

1. `into_text` no longer copies the body. `Response.body` becomes
   `Vec<u8>`, so the valid-UTF-8 case — effectively every page — hands
   its allocation straight to `String` instead of allocating a second
   full-size buffer and memcpy'ing into it. Measured: 10 KB 7.0 -> 1.8
   us, 500 KB 337 -> 55 us, 16 MB 5269 -> 1508 us. Peak resident per
   in-flight fetch drops from 2x to 1x the body size (100 MB -> 50 MB at
   MAX_BODY_BYTES). A second effect compounds it: `String::from_utf8`'s
   validation beats `from_utf8_lossy`'s chunked scan on ASCII-dominant
   input, 512 KB pure ASCII 122 -> 9.4 us.

   Invalid UTF-8 must stay byte-identical, so the fallback decodes
   `e.as_bytes()` rather than re-deriving from the moved buffer. Covered
   by a 19-case equivalence table (BOM, truncated 2/3/4-byte sequences,
   lone surrogate, overlong NUL, >U+10FFFF, windows-1252, bad bytes at
   buffer edges) asserting the output matches the old implementation
   exactly, plus a pointer-identity test proving the valid path does not
   re-allocate. Charset handling is deliberately untouched.

2. `pool_max_idle_per_host(8)` -> 32. wreq's default is `usize::MAX`, so
   this call was REMOVING pooling capacity, not adding it. At 20
   concurrent requests to an HTTP/1.1 origin, 8 connections were pooled
   and 12 closed on release — a 60% reconnect rate in steady state, each
   costing a fresh TCP + TLS handshake. Kept bounded rather than removed
   because `BrowserProfile::Random` builds six clients, each with its own
   pool.

3. Dropped a redundant `HeaderMap` clone in the extract path; both
   consumers take `&HeaderMap`. 26 allocations / ~2.8 KB per fetch.

Deliberately NOT done: seeding the buffer with `content_length()` as a
capacity hint. It is `None` for essentially every HTML page (the
decompression layer reports no exact size hint), and where it is `Some`
it is an unverified attacker-chosen wire value — `Content-Length:
52428799` followed by FIN would commit 50 MB before the first byte
arrives, inverting the property `check_body_ceiling` exists to preserve.
A fixed hint measured slower at 16 MB and over-allocates 6.8x for a
small page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CrawlResult.pages` collects every page's full extraction and the crawl
only returns once it has finished, so peak memory is O(pages x page
size) with all of it live at once. Measured at ~90 KB per extracted
page: ~440 MB for 5 000 pages, ~4.4 GB for 50 000. That, not the crawl
itself, is what the page ceilings are protecting.

`progress_tx` already streamed each page as it completed, but the page
was pushed onto `pages` regardless, so a streaming consumer paid the
full retention cost anyway. `stream_only` drops the page once reported.
Peak memory then stays flat regardless of page count, which is what
makes raising or removing a ceiling viable.

Page counting moves off `pages.len()` to explicit counters, so
total/ok/errors stay correct when nothing is retained — those are all a
streaming consumer has left. In stream-only mode the page is moved into
the channel rather than cloned, since nothing downstream retains it.

Defaults to false: every existing caller keeps collecting, and a test
pins that default because flipping it would silently empty
`CrawlResult::pages` for all of them.

Not addressed here, and still bounding a very large crawl: the visited
set and frontier are held as owned Strings (frontier capped at 10x
max_pages), so they grow with the URL space rather than the page count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CrawlConfig.max_pages` was a required `usize`, so an unbounded crawl
could not be asked for at any layer — the engine always stopped at a
number. A page cap is a policy decision (quota, budget, patience) that
belongs to whoever is paying for the crawl, not to the extraction
engine. Now `Option<usize>`, with `None` meaning "run until the frontier
is exhausted or the caller cancels".

Uncapped is opt-in everywhere; nothing changes for a caller who says
nothing:
  - CrawlConfig::default() still caps at 50.
  - CLI `--max-pages 0`, MCP `max_pages: 0`, OSS server `max_pages: 0`.
  - OSS server's HARD_MAX_PAGES = 500 ceiling is gone. An omitted value
    still defaults to 50 so a request that asks for nothing in
    particular cannot run forever on a synchronous endpoint.

The frontier bound stays, and is now independent of the page cap. It is
a MEMORY rail, not a page limit: it bounds queued-but-unfetched URLs,
which are owned Strings held live for the whole crawl. Search listings,
tag clouds and faceted navigation emit thousands of links per page, and
a calendar-style URL space is effectively infinite — without this an
uncapped crawl dies on memory rather than finishing its work. Capped
crawls keep the existing 10x/5x behaviour; uncapped ones use a fixed
500k ceiling, roughly 50 MB of pending frontier.

The CLI and the OSS crawl route both render the full result at the end,
so they still collect pages; an uncapped crawl there grows with the
site. `stream_only` is what makes an uncapped crawl flat in memory, and
suits an async consumer. Also switches the resume-state page count off
`pages.len()` to `total`, which stays correct when nothing is retained.

BREAKING (minor) for downstream: `CrawlConfig.max_pages` is now
`Option<usize>`. Call sites need `Some(n)`.

Verified end to end: depth-1 uncapped crawl ran to 270 pages and stopped
when the frontier emptied, not at a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`FetchConfig.timeout` did not mean what it reads as. The HTTP client
applies it as two independent budgets — one for the response head, then
a fresh one armed when the head arrives for the body — so a single
attempt takes up to 2x. The retry loop then runs two attempts with a 1s
pause between them. A `timeout: 12s` config therefore permitted 49s, and
nothing in the API said so. A request occupying a worker for 49s when
the caller asked for 12 is a reliability problem, not a slow page.

Adds `total_timeout`: a ceiling on one complete `fetch` covering every
attempt, redirect and body read. Both retry loops now start a clock
before the first attempt and run each attempt inside the remaining
share, so the retry pause comes out of the same budget. An attempt is
not started at all once the budget is spent, rather than being started
and then abandoned.

Defaults to `2 * timeout` (24s): a single slow-but-successful fetch
keeps its full head+body allowance and is unaffected, while the retry
can no longer extend past it. Retries exist for transient failures,
which fail fast; retrying after a full-budget timeout rarely succeeds
and always costs the caller another full budget. `None` restores the old
compounding behaviour for anyone who wants it.

`timeout`'s doc comment now states the 2x behaviour rather than leaving
it to be discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/v1/batch` refused more than 100 URLs. The cap existed because the
aggregate response is built entirely in memory before anything is sent,
so a large batch grew the process with the request — but that is a
property of the response *shape*, not a sensible policy. Capping the
count only moved the failure somewhere less obvious, and told callers
with real work to go elsewhere.

Adds `FetchClient::fetch_and_extract_batch_stream`, which yields each
result as it completes. The existing batch fns spawn a task per URL up
front and collect everything into a Vec, so both task count and memory
scale with the input; this polls at most `concurrency` futures at a time
via `buffer_unordered` and hands each result straight to the consumer.
Results arrive in completion order — that is the point — so the ordered
fns stay for callers that need order and can afford to buffer.

`/v1/batch` gains `"stream": true`, which responds with NDJSON: one
object per line, flushed as each URL lands. Nothing accumulates
server-side, so HARD_MAX_URLS is gone. Verified against the self-host
server: 150 URLs (past the old cap) all succeeded, and on a 100-URL
batch the first line arrived at 0.10s of a 2.41s run — incremental, not
buffered then dumped.

`concurrency` stays bounded at 20. That one is politeness toward the
sites being fetched, not a limit on the caller, and the doc comment now
says so rather than implying it is about memory.

A serialisation failure emits a parseable error line for that URL
instead of tearing down the stream mid-batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dget

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's `cargo clippy --all -- -D warnings` rejected `Ok(builder...?)` in the
NDJSON branch. The builder already returns the handler's error type, so the
wrapper was pure noise.

Missed locally because verification ran `cargo clippy --all --examples`,
and `--examples` RESTRICTS target selection to examples rather than adding
them to the default set — so the binaries, including the one that failed,
were never linted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: uncapped crawl, streaming batch, and one real fetch budget
CHANGELOG conflicted because both sides inserted at the same point in the
[Unreleased] block: #98 added a Performance entry, this branch added the
released [0.6.17] section. Both belong — the Performance note stays under
[Unreleased] (it is not released yet) and [0.6.17] sits below it as a cut
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(release): 0.6.17 — drop competitor name from zh README, reconcile version
@0xMassi
0xMassi merged commit 727b8c1 into main Aug 5, 2026
4 checks passed
@0xMassi
0xMassi deleted the staging branch August 5, 2026 19:56
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