diff --git a/.gitignore b/.gitignore index f96e379fbb304..c1858d7cc5c09 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ wp-tests-config.php /tests/phpunit/data/.trac-ticket-cache* /tests/qunit/compiled.html /tests/performance/**/*.test.results.json +/tests/performance/html-api/corpus/real/ /src/.wp-tests-version /node_modules /npm-debug.log diff --git a/tests/performance/html-api/README.md b/tests/performance/html-api/README.md new file mode 100644 index 0000000000000..acf97c3c0e015 --- /dev/null +++ b/tests/performance/html-api/README.md @@ -0,0 +1,182 @@ +# HTML API parsing benchmark + +Measures how fast `WP_HTML_Tag_Processor` and `WP_HTML_Processor` parse documents, and whether a code change made parsing faster or slower, with a confidence interval. It is a development tool under `tests/performance/html-api/`; nothing here ships with WordPress. + +## What is measured + +The timed loop constructs a processor and calls `next_token()` until it returns `false`: + +```php +$p = new WP_HTML_Tag_Processor( $html ); // or WP_HTML_Processor::create_full_parser( $html ) +while ( $p->next_token() ) { ++$tokens; } +``` + +Construction is inside the timed region because real use constructs a processor per document. Nothing else is: no `get_tag()`, no attribute reads, no modifications, no output. A document the HTML Processor cannot finish (`get_last_error()` is not null) is still timed, but it is listed separately and left out of the aggregate. + +Memory is a secondary metric: the single-tree table shows the allocator peak above the pre-run level for one parse (`peak_alloc_bytes` in the JSON); the JSON also carries `peak_bytes`, the delta of `memory_get_peak_usage( true )`, which moves in 2 MB steps. + +The workers load the HTML API files straight from a checkout's `src/wp-includes/` with a few function stubs (`__()`, `esc_url()`, `wp_kses_uri_attributes()` and similar; see `lib/class-benchmark-bootstrap.php`). WordPress itself is not loaded, so hooks, KSES and the rest of the request lifecycle are not measured. + +## Running + +Single tree, from a checkout: + +``` +php tests/performance/html-api/bench.php +``` + +A/B between two checkouts: + +``` +php tests/performance/html-api/bench.php --base ~/a8c/wordpress-develop/trunk --head . +``` + +`--head` defaults to the checkout containing `bench.php`. Both checkouts use the same `worker.php` and bootstrap from the tool directory of the `bench.php` you ran, so `--base` may point at a checkout that predates this tool. + +Options (`--name value` or `--name=value`): + +| Option | Default | Meaning | +| --- | --- | --- | +| `--base ` | none | Baseline checkout; enables A/B mode. | +| `--head ` | the checkout containing bench.php | Checkout under test. | +| `--php ` | `php` | PHP binary for the workers. | +| `--php-args ""` | none | Extra arguments for both workers. | +| `--opcache` / `--no-opcache` | on when the extension is available | Adds `-d opcache.enable_cli=1`. | +| `--jit` | off | Adds `-d opcache.jit_buffer_size=64M -d opcache.jit=tracing`. | +| `--parser tag\|html\|both` | `both` | Which parser to time. | +| `--corpus ` | `corpus/real` when it has files | Directory of `*.html` or `*.htm`; repeatable. The id is the file name. | +| `--no-corpus` | | Skip `corpus/real`; only directories named with `--corpus` are read. | +| `--include-optional` | | Include corpus documents whose manifest entry is `optional` (the 15 MB single-page HTML standard). | +| `--synthetic` / `--no-synthetic` | on | Include the generated shapes. | +| `--synthetic-size ` | 200000 | Target size of each generated document. | +| `--seed ` | 1 | Seed for generation and for the bootstrap. | +| `--filter ` | none | Keep ids containing the text, or matching it when written as `/regex/`. | +| `--samples ` | 10 | Timed samples per document, parser and tree, per round. | +| `--min-sample-ms ` | 25 | Each sample runs enough parses to last at least this long on the slower tree. | +| `--warmup ` | 2; 10 with `--jit` | Untimed parses before sampling. | +| `--warmup-ms ` | 0; 200 with `--jit` | Keep warming up until this much time has passed as well. | +| `--rounds ` | 4 | Repeat the whole run with a fresh pair of worker processes each time; see below. | +| `--format table\|markdown\|json` | `table` | Report format. | +| `--save ` | none | Write the full JSON (every sample, config, environment) whatever the format. | +| `--quiet` | | No progress on stderr. | +| `--list` | | Print document ids and sizes and exit. | + +Progress goes to stderr, one line per document and parser; the report goes to stdout. + +## How a run proceeds + +1. `bench.php` takes the benchmark lock (see below), then starts one worker per tree (`php [ini flags] worker.php --checkout `). Both workers stay alive for the whole round. A worker with a profiler extension loaded (Xdebug, Excimer, SPX, XHProf and similar) stops the run. +2. Each round runs the (document, parser) pairs in a shuffled order, seeded from `--seed` and the round number; both trees use the same order. For each pair: the document is loaded into both workers if it is not already; each runs at least `--warmup` untimed parses for at least `--warmup-ms`; each runs one calibration parse, and the slower of the two sets the number of parses per sample so that a sample lasts at least `--min-sample-ms`. +3. The samples are taken alternating between trees, and the tree that goes first alternates each sample (A B, B A, A B, ...). Each sample calls `gc_collect_cycles()` first, then times its parses with `hrtime( true )`. +4. The per-sample measure is nanoseconds per parse. Per document, parser and tree: median, min, mean, standard deviation, coefficient of variation (CV), MB/s and tokens/s from the median. + +## Reading the output + +A/B table, one row per document: size, median per tree, change in percent (head over base; negative is faster), a 95% confidence interval on that change, the verdict, and the CV of each tree's samples. + +- `faster`: the whole interval is below 0%. +- `slower`: the whole interval is above 0%. +- `no difference`: the interval contains 0%. This is "not shown to differ at this precision", not "identical". + +The overall line per parser is the geometric mean of the per-document ratios with its own interval, and the throughput of each tree as total bytes over the sum of the median times. Documents that bailed on either tree are listed after it and are not in the aggregate. + +The CV columns say how noisy the samples were. On an otherwise idle machine expect 0.2% to 1%. Several percent means something else was running, or the machine is throttling; rerun. A row whose CV is above 10% on either tree is marked `(noisy)`: its absolute times are not usable, although the interleaving still protects the ratio when both trees were slowed alike. On Apple Silicon a worker that lands on an efficiency core while other work saturates the performance cores runs several times slower for as long as it stays there, and both workers move together; this showed up as 6x slower samples on both trees at once while other jobs ran on this machine. + +### Where the interval comes from + +With one round (`--rounds 1`), the interval is a bootstrap: 2000 times, draw head samples and base samples with replacement, take the ratio of their medians; the interval is the 2.5th to 97.5th percentile of those ratios. The overall interval resamples documents and then samples within each. The bootstrap is seeded from `--seed`, so a saved run recomputes to the same numbers. + +The bootstrap only sees the spread between samples of one process. Two PHP processes running identical code do not run at identical speed: memory layout and scheduling give each process its own offset, which on this machine is 0.3% to 1% and which is constant for the life of the process, so interleaving cannot cancel it. When the samples are tight (CV around 0.3%), the one-round interval is narrower than that offset, and an A/A run returns `faster` or `slower` on some rows at the 0.5% level. + +`--rounds N` with N of 2 or more (the default is 4) starts a fresh pair of workers for each round and repeats the whole schedule. Each round then has its own ratio of medians per document, and the interval is a t-interval over the per-round log ratios (N minus 1 degrees of freedom); the overall interval is a t-interval over the per-round geometric means. The point estimate becomes the geometric mean of the per-round ratios, and the per-tree columns show the mean of the per-round medians. With 3 rounds the interval covers changes of about 2%; with 5 rounds about 1%. Use rounds when the claim is below a few percent. + +### The tracing JIT + +With `--jit`, a process compiles traces for the code paths it runs first, and those traces set the speed of everything it runs afterwards. On this corpus, a process that parsed wordpress.org's home page first ran it and every later document 10% to 16% slower than a process that started with a Wikipedia article or RFC 9110. That is why each round shuffles its order: rounds then differ in history, and the interval over rounds includes the effect. The HTML Processor needs about six parses before its traces settle, against three for the Tag Processor, which is why `--jit` raises the warmup. The saved JSON records each round's order under `orders`. + +### A/A validation + +Before trusting a result on a given machine, run with the same checkout on both sides: + +``` +php tests/performance/html-api/bench.php --base . --head . +``` + +Expect the overall line per parser to say `no difference` and the per-row verdicts to be `no difference` with at most the occasional row crossing 0% by a few tenths of a percent. With 64 rows at 95% confidence, a few false verdicts per run are within expectation even on a perfect machine. If many rows disagree, or the overall interval excludes 0%, the machine is too noisy: close other work, raise `--samples`, or use `--rounds 5`. With `--rounds 1` an A/A run on this machine reported `slower` at +1.8% on every HTML Processor row with CVs of 0.2%: that is the per-process offset, and it is why the default is several rounds. The second-started worker of a pair is the slower one, so the round count defaults to an even number: with 3 rounds, base starts first in two of them and an A/A run reported `slower` on 8 of 64 rows, all in the same direction; with 4 rounds the start order is balanced. + +### Why interleave and bootstrap, rather than run each tree once and compare means + +Machine speed drifts over seconds and minutes (thermal state, background work, frequency scaling). Two back-to-back runs put all of that drift into the difference. Alternating A and B within each sample, and alternating which goes first, puts both trees into the same drift so it cancels in the ratio. The mean of timing samples is pulled by outliers (a GC pass, a scheduler interruption); the median is not. A single difference of means has no error bar; the bootstrap gives the ratio an interval without assuming a distribution for the samples, so a reader can tell a 1% change with a 0.3% interval from a 1% change with a 4% interval. + +`compare.php` exists for saved files from two separate runs. It computes the same statistics but says in its header that the runs were not interleaved, so drift between the runs is in the result. + +## Synthetic documents + +`lib/class-benchmark-synthetic.php` generates one document per shape, each a full document (doctype, html, head, body) sized to `--synthetic-size` within 2%, deterministic for a given shape, size and seed on every PHP version. Shape ids and what each exercises are in that file; `bench.php --list` prints them. + +To add a shape: add its id to `Benchmark_Synthetic::shapes()` and a branch to `generate()` that builds the body with `mt_rand()` only (never `random_int()` or `rand()`), pads or trims to the target size, and wraps it in the shared document skeleton. Check that `WP_HTML_Processor::create_full_parser()` parses it to the end on trunk (`bench.php --filter --parser html --samples 2` shows `bailed` if not), and say in the file header if it cannot. + +## Real corpus + +`corpus/manifest.json` lists about twenty public pages by URL with the size and sha256 of the bytes fetched on the manifest date: the WHATWG HTML standard's parsing section, the W3C HTML5 Recommendation's syntax chapter, RFC 9110, Wikipedia articles in several scripts pinned by revision id, pages rendered by WordPress (wordpress.org, developer.wordpress.org, make.wordpress.org, sirre.al), an MDN reference page, a php.net manual page and the Hacker News front page. Each entry says what the page is, whether its bytes are pinned, and whether the HTML Processor bails on it on trunk (one does: a caching-plugin comment after ``). `corpus/real/` is gitignored. To fetch: + +``` +php tests/performance/html-api/fetch-corpus.php +``` + +It fetches with curl, verifies the hash, and skips documents already present. A hash mismatch on a pinned entry is an error and the file is removed; on an unpinned entry it is a warning, the file is kept, and the page is reported as drifted since the manifest date. `--include-optional` also fetches the single-page HTML standard (15 MB), which `bench.php` likewise skips unless given `--include-optional`. `--update-manifest` refetches everything, rewrites sizes, hashes and dates, and re-checks which documents bail. `bench.php` picks up `corpus/real` automatically when it has files. + +## Your own documents + +Point `--corpus` at any directory of `.html` files, repeatable, with `--no-synthetic` to measure only those: + +``` +php tests/performance/html-api/bench.php --corpus ~/pages --no-synthetic --base ~/a8c/wordpress-develop/trunk +``` + +## Comparing saved runs + +``` +php tests/performance/html-api/bench.php --save before.json +# change code +php tests/performance/html-api/bench.php --save after.json +php tests/performance/html-api/compare.php before.json after.json +``` + +The head tree of each file is used, matched by document id and parser. Prefer a live A/B when both checkouts are available. + +## Checking that a change keeps behavior + +A faster parser is only useful if it parses the same way. `difftrace.php` compares a head checkout with a base checkout: + +``` +php tests/performance/html-api/difftrace.php --base ~/a8c/wordpress-develop/trunk --head . +``` + +For the Tag Processor it loads both checkouts' `class-wp-html-tag-processor.php` into one process under renamed classes (the other HTML API files come from base), walks each input seven ways (`next_token()`, `next_tag()`, `next_tag()` visiting closers, `next_tag()` with queries, with attribute, class and text modifications, with bookmarks and seeks, and with both mixed), applies the same action to both, and compares the return value, every public accessor and every `_doing_it_wrong()` call after every step, then the final `get_updated_html()`. For the HTML Processor it runs each checkout in its own process and compares a hash of the token stream (type, name, breadcrumbs, namespace, attributes, modifiable text, final error) per input, walked once plain and once setting attributes and calling `get_updated_html()` along the way, and shows the first differing token for up to five inputs. + +The inputs are the real corpus, the synthetic shapes at 5 KB and 100 KB, about 270 hand-written edge cases with every prefix of each (which exercises incomplete input), and 20,000 mutated slices of the corpus (`--mutations`). A run takes about a minute and a half and exits 1 when anything differs. Removing `\r` from the whitespace skipped around `=`, or the `XMP` case from the special-element switch, each produce hundreds of differing inputs. + +## Timed runs take a lock + +Two timed runs at once slow each other and corrupt both results. `bench.php` holds an exclusive lock on `/tmp/wp-html-api-bench.lock` from the first worker to the report, and waits, saying so on stderr, while another run holds it. Run any other timing or profiling through the same lock: + +``` +php tests/performance/html-api/with-lock.php php -d extension=excimer.so profile-script.php +``` + +## Files + +- `bench.php`: the orchestrator. +- `worker.php`: a subprocess that loads one checkout's HTML API and runs timed jobs read as JSON lines on stdin, answering on stdout. +- `compare.php`: compares two `--save` files. +- `with-lock.php`: runs a command while holding the benchmark lock. +- `difftrace.php`: checks that a head checkout parses every input the same way as a base checkout. +- `fetch-corpus.php`: restores `corpus/real/` from `corpus/manifest.json`. +- `lib/class-benchmark-bootstrap.php`: loads the HTML API from a checkout root with stubs for the WordPress functions it calls. +- `lib/class-benchmark-synthetic.php`: the shape generator. +- `lib/class-benchmark-stats.php`: median, bootstrap, t-interval, geometric mean, formatting. +- `lib/class-benchmark-report.php`: table, markdown and JSON output. +- `lib/class-benchmark-lock.php`: the lock shared by `bench.php` and `with-lock.php`. + +`bench.php` and `compare.php` need PHP 8.1 or newer; the workers run on whatever `--php` names, as long as the checkout's HTML API loads there. diff --git a/tests/performance/html-api/SPEC.md b/tests/performance/html-api/SPEC.md new file mode 100644 index 0000000000000..f79d20cb37245 --- /dev/null +++ b/tests/performance/html-api/SPEC.md @@ -0,0 +1,176 @@ +# HTML API parsing benchmark: design spec (2026-10-01) + +Worktree: ~/a8c/wordpress-develop/html-api-benchmark, branch `html-api/benchmark`, off origin/trunk 4277884c40. +Tool root: `tests/performance/html-api/` in that worktree. Everything below is relative to the tool root unless stated. +PHP on this machine: `php` is 8.5.11; `/opt/homebrew/opt/php@8.4/bin/php` is 8.4. No 7.4. Code must still be PHP 7.2-compatible in syntax where it loads core files, but the tool itself may require PHP 8.1+ (it is a dev tool, not shipped code). Use WordPress coding style (tabs, snake_case, Yoda conditions, spaces inside parens) so it passes the repo's phpcs if ever run. + +## Purpose + +Measure parsing throughput of `WP_HTML_Tag_Processor` and `WP_HTML_Processor` with enough rigor to say whether a code change made parsing faster or slower, and by how much, with a confidence interval. Parsing only: the timed loop calls `next_token()` until it returns false. No `get_tag()`, no attribute reads, no modifications in the timed loop. + +## Layout + +``` +tests/performance/html-api/ + README.md usage, design, how to read the output, how to add a shape + bench.php CLI entry: orchestrates workers, collects samples, computes stats, prints report + worker.php subprocess: loads one checkout's HTML API, runs timed jobs read from stdin as JSON lines, writes JSON lines to stdout + compare.php compares two saved JSON result files (no interleaving; weaker than live A/B, says so in output) + fetch-corpus.php restores corpus/real/*.html from corpus/manifest.json via HTTP range requests to data.commoncrawl.org + lib/ + class-benchmark-bootstrap.php loads the HTML API files from a given checkout root plus WordPress function stubs + class-benchmark-synthetic.php seeded synthetic document generator + class-benchmark-stats.php median, bootstrap CI, geometric mean, formatting + class-benchmark-report.php table / markdown / json output + corpus/ + manifest.json Common Crawl record pointers + sha256 of expected body (committed) + real/ fetched documents (gitignored) + .gitignore `real/` +``` + +## Bootstrap (lib/class-benchmark-bootstrap.php) + +`Benchmark_Bootstrap::load( string $checkout_root ): void` requires, in this order, from `$checkout_root`: + +``` +src/wp-includes/compat.php +src/wp-includes/compat-utf8.php (only if the file exists; older trunks lack it) +src/wp-includes/utf8.php (only if exists) +src/wp-includes/class-wp-token-map.php +src/wp-includes/html-api/html5-named-character-references.php +src/wp-includes/html-api/class-wp-html-attribute-token.php +src/wp-includes/html-api/class-wp-html-span.php +src/wp-includes/html-api/class-wp-html-doctype-info.php +src/wp-includes/html-api/class-wp-html-text-replacement.php +src/wp-includes/html-api/class-wp-html-decoder.php +src/wp-includes/html-api/class-wp-html-tag-processor.php +src/wp-includes/html-api/class-wp-html-unsupported-exception.php +src/wp-includes/html-api/class-wp-html-active-formatting-elements.php +src/wp-includes/html-api/class-wp-html-open-elements.php +src/wp-includes/html-api/class-wp-html-token.php +src/wp-includes/html-api/class-wp-html-stack-event.php +src/wp-includes/html-api/class-wp-html-processor-state.php +src/wp-includes/html-api/class-wp-html-processor.php +``` + +Before those, define stubs guarded by `function_exists`: `__`, `_doing_it_wrong` (no-op), `_deprecated_argument`, `wp_trigger_error`, `esc_url` (identity), `wp_kses_uri_attributes` (the usual list), `wp_scrub_utf8` (identity if missing; check whether utf8.php defines it). Reference: the fuzz harness stubs at ~/a8c/wordpress-develop/html-api-commoncrawl/tools/html-api-fuzz/lib/wp-stubs.php. If a required file is missing in the checkout, fail with a clear message naming the file. + +## Worker protocol (worker.php) + +Started as `php [ini flags] worker.php --checkout `. Loads bootstrap once. Then reads one JSON object per line on stdin and answers one JSON object per line on stdout. The worker never writes anything else to stdout; diagnostics go to stderr. + +Request kinds: + +- `{"op":"hello"}` → `{"ok":true,"php":"8.5.11","opcache":bool,"jit":"","checkout":"","head":""}` +- `{"op":"load","id":"","path":""}` or `{"op":"load","id":"...","html":""}` → stores the document in memory keyed by id; replies `{"ok":true,"id":..., "bytes":N}`. +- `{"op":"calibrate","id":"...","parser":"tag"|"html"}` → runs one parse, replies `{"ok":true,"ns":,"tokens":N,"bailed":null|""}`. +- `{"op":"run","id":"...","parser":"tag"|"html","iterations":N}` → runs N full parses back to back inside one hrtime window, replies `{"ok":true,"ns":,"tokens":,"bailed":null|"","peak_bytes":}`. Before timing, call `gc_collect_cycles()`. Create a fresh processor per iteration (construction is part of parsing a document in real use; keep it in). +- `{"op":"quit"}` → exits 0. + +Tag Processor parse: `$p = new WP_HTML_Tag_Processor( $html ); while ( $p->next_token() ) { ++$tokens; }`. +HTML Processor parse: `$p = WP_HTML_Processor::create_full_parser( $html ); while ( $p->next_token() ) { ++$tokens; }` then if `$p->get_last_error() !== null` the parse bailed: report `get_last_error()` plus `get_unsupported_exception()?->getMessage()` as the reason. For older checkouts without `create_full_parser`, fall back to `create_fragment`. A bailed document is still timed (time to bail), but bench.php reports it separately (see Report). + +Memory: `memory_get_peak_usage(true)` before and after; report peak. Secondary metric only. + +## Orchestrator (bench.php) + +Options (long form, `--name=value` or `--name value`): + +- `--head ` defaults to the checkout containing bench.php (resolve by walking up to the dir containing `src/wp-includes/html-api`). +- `--base ` optional. When given, A/B mode. When omitted, single-tree mode. +- `--php ` default `php`. `--php-args ""` passed to both workers. +- `--opcache` (default on if the opcache extension is available) adds `-d opcache.enable_cli=1`; `--no-opcache`. `--jit` adds `-d opcache.jit_buffer_size=64M -d opcache.jit=tracing`; default off. The report header prints what was in effect. +- `--parser tag|html|both` default both. +- `--corpus ` repeatable; default `corpus/real` if it exists and is non-empty. Reads `*.html` and `*.htm`, document id is the filename. +- `--synthetic` / `--no-synthetic` default on. `--synthetic-size ` default 200000. `--seed ` default 1. +- `--filter ` restrict document ids. +- `--samples N` default 20: number of timed samples per (document, parser, tree). +- `--min-sample-ms N` default 25: iterations per sample are calibrated so a sample lasts at least this long on the slower tree (one calibration parse per tree, take the max, iterations = max(1, ceil(min_sample_ms / ms_per_parse))). +- `--warmup N` default 2 untimed parses per (document, parser, tree) before sampling. +- `--format table|markdown|json` default table. `--save ` writes the full JSON (every sample, config, environment) regardless of format. `--quiet`. +- `--list` prints document ids and sizes and exits. + +Scheduling: both workers start at once and stay alive for the whole run. For each (document, parser): load into both, warm up both, calibrate, then for s in 1..samples: run on A, run on B, alternating which goes first each sample (ABBA ordering: sample 1 A then B, sample 2 B then A, ...). This interleaving is the point of the design: machine noise drifts over time and alternating cancels drift. Print progress to stderr with a one-line-per-document status unless `--quiet`. + +In single-tree mode, run the same loop with one worker. + +## Statistics (lib/class-benchmark-stats.php) + +Per sample, the measure is ns per parse = ns / iterations. Per (document, parser, tree): median, min, mean, stddev, CV (stddev/mean), and throughput MB/s = bytes / median, tokens/s. + +A/B per document: ratio = median_head / median_base (<1 is faster). 95% CI from a bootstrap of the ratio of medians: 2000 resamples, each drawing with replacement from head samples and base samples, percentile interval. Verdict: `faster` if the whole CI is below 1, `slower` if whole CI above 1, else `no difference`. Also report the point change as a percentage. + +Overall per parser: geometric mean of per-document ratios, with a bootstrap CI (resample documents and samples). Also overall throughput: sum(bytes) / sum(median ns) per tree. Report documents that bailed on either tree in their own list, excluded from the aggregate. + +Use `hrtime(true)` only. The bootstrap must be deterministic given `--seed` (mt_srand). + +## Synthetic documents (lib/class-benchmark-synthetic.php) + +`Benchmark_Synthetic::shapes(): array` of ids. `Benchmark_Synthetic::generate( string $shape, int $target_bytes, int $seed ): string`. Deterministic: same (shape, bytes, seed) gives identical bytes across runs and PHP versions (use `mt_srand( $seed, MT_RAND_MT19937 )` and `mt_rand`; never `random_int`). Output size within 2% of target. Every shape is a full document (doctype, html, head, body) so the HTML Processor runs it in full-parser mode without bailing; verify each shape parses to the end with `WP_HTML_Processor::create_full_parser` on trunk (no `get_last_error()`), and note any that cannot in the README. + +Shapes (ids): +- `tags-dense`: short elements with no attributes, little text: `
` style, mixed nesting depth up to 8. +- `text-long`: a few `

` elements holding long runs of plain ASCII prose (no entities, no `<` or `&`). +- `text-entities`: prose with frequent named and numeric character references (`&`, ` `, `’`, `…`, some invalid like `¬anentity;`). +- `text-utf8`: prose of multi-byte UTF-8 (mixed scripts), no entities. +- `comments-many`: thousands of short comments between elements, including some with `--` inside and some bogus comments `` and ``. +- `attributes-heavy`: elements with 10 to 30 attributes each, quoted and unquoted values, duplicates, boolean, long `class` and `style` values, `data-*` names. +- `nesting-deep`: nesting to depth 200 then unwinding, repeated (the HTML Processor stack of open elements under load). +- `script-style`: large `', + '', + '', + '', + '', + '', + '', + '-->', + '', + '-->', + '', + "", + '', + "", + '&x', + '<b>', + '

<b>', + '', + '<b>', + '<b>', + '', + '<b>', + '<plaintext>', + '<a href=x>', + "<a href='x'>", + '<a href=x/>', + '<a/b/c>', + '<a =b>', + '<a ==b>', + '<a b c d>', + '<a b=c b=d>', + '<a B=c b=d>', + "<a\tb\nc\fd\re>", + '<a b="&amp;&lt;&#x41;&#65;&notin;&notit;&amp">', + '<a b=&amp;c>', + '<a class="x y z x">', + '<a class>', + '<a class="">', + '<a CLASS="A a">', + '<a class="x" class="y">', + '<a title="a>b">', + '<a title=a>b>', + '<a b="c"d="e">', + '<a b=">"c>', + '<a "b"=c>', + "<a 'b'>", + '<a <b>', + '<a<b>', + '<a b<c>', + '<div>text &amp; more</div>', + "a\0b", + "<a\0b>", + "<\0>", + "<a b=\"\0\">", + '<1>', + '<-a>', + '< a>', + '<a.b>', + '<A-B:C>', + '<br/>', + '<br />', + '<br/ >', + '<img src=x/>', + '<a b/>c', + '<div/>', + '<input disabled/>', + 'x&#0;y', + '&#x110000;', + '&', + '&amp', + '&;', + '&#', + '&#x', + '&#x;', + '&#99999999999;', + '&notin', + '&NotNestedLessLess;', + '&AMP;', + '&amp;amp;', + '<p>a<p>b', + "\xEF\xBB\xBF<p>", + "<p>\xC3\xA9\xF0\x9F\x98\x80</p>", + "<p>\xFF\xFE</p>", + "<p title=\"\xC3\">x</p>", + "<\xC3\xA9>", + '<template><td>x</template>', + '<table><tr><td>x</table>', + '<table>x<tr>', + '<select><option>x</select>', + '<ul><li>a<li>b</ul>', + '<b><i>x</b></i>', + '<a><a>x', + '<p><div>x</p></div>', + '<html><head><title>t</title></head><body><p class="x" id=y>Hello<br>world</p></body></html>', + str_repeat( '<div a=b>', 50 ) . str_repeat( '</div>', 50 ), + '<a ' . str_repeat( 'b ', 200 ) . '>', + '<p>' . str_repeat( ' ', 300 ) . '</p>', + ); + + // Special elements: case, near-miss names, NUL bytes, and leading newlines. + array_push( + $cases, + '<TITLE>a</title>', + '<titlex>y</titlex>', + "<listing>\nx</listing>", + "<LISTING>\r\nx", + '<svg><title>a</title></svg>', + '<textarea/>a</textarea>', + '</script>', + "<scri\0pt>x</script>", + "<title\0>x</title>", + "<t\xC4\xB1tle>x</title>", + '<IFRAME>x</iframe>', + '<NoEmBeD>x</noembed>', + '<NOFRAMES>x</noframes>', + '<Xmp>x</xmp>', + '<scripts>x</scripts>', + '<styles>x', + "<pre>\n\nx</pre>", + "<pre a=b>\nx</pre>", + "<textarea>\n\nx</textarea>", + '<p><xmp>b</xmp>c', + '<p><title>b</title>c', + '<table data-a=1><textarea>x</textarea>', + "<table><tr><td><pre><br><td>\ny</table>", + "<pre>\nx</pre><listing>\ny</listing>" + ); + + // Every whitespace byte at every place the tokenizer skips or ends on whitespace. + foreach ( array( ' ', "\t", "\f", "\r", "\n", "\r\n" ) as $ws ) { + $cases[] = "<a{$ws}b{$ws}={$ws}c{$ws}d{$ws}={$ws}\"e\"{$ws}f{$ws}={$ws}'g'{$ws}h{$ws}/{$ws}>"; + $cases[] = "<a b{$ws}>x</a{$ws}>"; + $cases[] = "<a{$ws}/{$ws}>"; + $cases[] = "<a b={$ws}>"; + $cases[] = "<a b{$ws}={$ws}"; + $cases[] = "<!DOCTYPE{$ws}html{$ws}PUBLIC{$ws}\"x\"{$ws}\"y\"{$ws}>"; + $cases[] = "<script>x</script{$ws}>y"; + $cases[] = "<textarea>{$ws}x</textarea{$ws}>"; + $cases[] = "<pre>{$ws}x</pre>"; + $cases[] = "<title{$ws}a=b>x</title{$ws}>"; + } + return $cases; +} + +/** + * Yields every input, as id => HTML, in the same order on every run with the same seed. + * + * @param int $seed Seed. + * @param int $mutations Number of mutated slices. + * @return Generator + */ +function difftrace_inputs( int $seed, int $mutations ): Generator { + $corpus = glob( __DIR__ . '/corpus/real/*.{html,htm}', GLOB_BRACE ); + sort( $corpus ); + $documents = array(); + foreach ( $corpus as $path ) { + $documents[ basename( $path ) ] = file_get_contents( $path ); + } + foreach ( $documents as $id => $html ) { + yield "corpus {$id}" => $html; + } + + foreach ( Benchmark_Synthetic::shapes() as $shape ) { + foreach ( array( 5000, 100000 ) as $size ) { + yield "synthetic {$shape} {$size}" => Benchmark_Synthetic::generate( $shape, $size, $seed ); + } + } + + foreach ( difftrace_edge_cases() as $n => $html ) { + $length = strlen( $html ); + for ( $i = 0; $i <= $length; $i++ ) { + yield "edge {$n} prefix {$i}" => substr( $html, 0, $i ); + } + } + + if ( empty( $documents ) || $mutations <= 0 ) { + return; + } + $sources = array_values( $documents ); + $alphabet = array( '<', '>', '/', '!', '-', '-', '=', '"', "'", ' ', "\t", "\n", "\0", '&', ';', '#', '?', '[', ']', 'a', 'x', 'S', 'T', '<!--', '-->', '</', '/>', '<script>', '</script>', '<textarea>', '<style>', '<title>', '<svg>', '<math>', '<![CDATA[', ']]>', '<!DOCTYPE html>', '&amp;', '&lt', "\xC3", "\xC3\xA9", "\r", "\f", "\r\n", ' = ', "\t" ); + $prefixes = array( '', '', '', '<script>', '<textarea>', '<style>', '<title>', '<!--', '<svg>', '<plaintext>', '<xmp>', '<a b="' ); + mt_srand( crc32( "mutations {$seed}" ), MT_RAND_MT19937 ); + for ( $m = 0; $m < $mutations; $m++ ) { + $source = $sources[ mt_rand( 0, count( $sources ) - 1 ) ]; + $length = mt_rand( 20, 600 ); + $start = mt_rand( 0, max( 0, strlen( $source ) - $length ) ); + $html = $prefixes[ mt_rand( 0, count( $prefixes ) - 1 ) ] . substr( $source, $start, $length ); + $edits = mt_rand( 1, 4 ); + for ( $e = 0; $e < $edits; $e++ ) { + $at = mt_rand( 0, strlen( $html ) ); + switch ( mt_rand( 0, 4 ) ) { + case 0: // Insert. + $html = substr( $html, 0, $at ) . $alphabet[ mt_rand( 0, count( $alphabet ) - 1 ) ] . substr( $html, $at ); + break; + case 1: // Replace one byte. + $html = substr( $html, 0, $at ) . $alphabet[ mt_rand( 0, count( $alphabet ) - 1 ) ] . substr( $html, $at + 1 ); + break; + case 2: // Delete a few bytes. + $html = substr( $html, 0, $at ) . substr( $html, $at + mt_rand( 1, 8 ) ); + break; + case 3: // Truncate. + $html = substr( $html, 0, $at ); + break; + default: // Whitespace beside an equals sign. + $equals = strpos( $html, '=', $at ); + if ( false !== $equals ) { + $where = $equals + mt_rand( 0, 1 ); + $html = substr( $html, 0, $where ) . substr( " \t\f\r\n", mt_rand( 0, 4 ), 1 ) . substr( $html, $where ); + } + } + } + yield "mutation {$m}" => $html; + } +} + +/** + * Loads a checkout's Tag Processor source under another class name. + * + * @param string $checkout Checkout root. + * @param string $class_name New class name. + */ +function difftrace_load_renamed( string $checkout, string $class_name ): void { + $source = file_get_contents( "{$checkout}/src/wp-includes/html-api/class-wp-html-tag-processor.php" ); + if ( false === $source ) { + fwrite( STDERR, "difftrace.php: no Tag Processor in {$checkout}\n" ); + exit( 2 ); + } + $source = preg_replace( '~\bWP_HTML_Tag_Processor\b~', $class_name, $source ); + $path = sys_get_temp_dir() . "/difftrace-{$class_name}-" . getmypid() . '.php'; + file_put_contents( $path, $source ); + require $path; + unlink( $path ); +} + +/** + * Everything observable about the current token through the public API. + * + * @param WP_HTML_Tag_Processor $p Processor. + * @return array Snapshot. + */ +function difftrace_snapshot( $p ): array { + $doctype = $p->get_doctype_info(); + $snapshot = array( + 'type' => $p->get_token_type(), + 'name' => $p->get_token_name(), + 'tag' => $p->get_tag(), + 'qualified' => $p->get_qualified_tag_name(), + 'namespace' => $p->get_namespace(), + 'closer' => $p->is_tag_closer(), + 'self_closing' => $p->has_self_closing_flag(), + 'comment_type' => $p->get_comment_type(), + 'comment_text' => $p->get_full_comment_text(), + 'text' => $p->get_modifiable_text(), + 'incomplete' => $p->paused_at_incomplete_token(), + 'doctype' => null === $doctype ? null : get_object_vars( $doctype ), + 'attributes' => null, + ); + $names = $p->get_attribute_names_with_prefix( '' ); + if ( null !== $names ) { + $snapshot['attributes'] = array(); + foreach ( $names as $name ) { + $snapshot['attributes'][ $name ] = array( $p->get_attribute( $name ), $p->get_qualified_attribute_name( $name ) ); + } + $snapshot['data_prefix'] = $p->get_attribute_names_with_prefix( 'data-' ); + $snapshot['probes'] = array( $p->get_attribute( 'CLASS' ), $p->get_attribute( 'missing' ), $p->has_class( 'x' ), $p->has_class( 'X' ) ); + $snapshot['classes'] = array(); + foreach ( $p->class_list() as $class_name ) { + $snapshot['classes'][] = $class_name; + } + } + return $snapshot; +} + +/** + * Applies one action to a processor and returns what it returned, any exception, and the warnings it raised. + * + * @param WP_HTML_Tag_Processor $p Processor. + * @param array $action Method name and arguments. + * @return array Outcome. + */ +function difftrace_apply( $p, array $action ): array { + $GLOBALS['difftrace_log'] = array(); + try { + $result = $p->{$action[0]}( ...array_slice( $action, 1 ) ); + } catch ( Throwable $e ) { + $result = 'threw ' . get_class( $e ) . ': ' . $e->getMessage(); + } + + // Messages name the renamed class through __METHOD__, and errors name a file and line; compare without them. + $renamed = array( 'WP_HTML_Tag_Processor_Base', 'WP_HTML_Tag_Processor_Head' ); + if ( is_string( $result ) && 0 === strpos( $result, 'threw ' ) ) { + $result = preg_replace( '~,? (?:called )?in \S+ on line \d+~', '', str_replace( $renamed, 'WP_HTML_Tag_Processor', $result ) ); + } + return array( + 'return' => $result, + 'log' => str_replace( $renamed, 'WP_HTML_Tag_Processor', $GLOBALS['difftrace_log'] ), + ); +} + +/** + * Walks one input with both Tag Processors in lockstep and returns the first difference, or null. + * + * @param string $id Input id. + * @param string $html Input. + * @param string $walk Walk name. + * @param int $seed Seed. + * @param int $steps Steps taken, incremented. + * @return array|null Difference. + */ +function difftrace_tag_walk( string $id, string $html, string $walk, int $seed, int &$steps ): ?array { + $a = new WP_HTML_Tag_Processor_Base( $html ); + $b = new WP_HTML_Tag_Processor_Head( $html ); + mt_srand( crc32( "{$seed} {$id} {$walk}" ), MT_RAND_MT19937 ); + + $limit = in_array( $walk, array( 'modify', 'bookmarks', 'mixed' ), true ) ? 20000 : PHP_INT_MAX; + $snapshot = null; + $bookmarks = array(); + $seeks = 0; + for ( $step = 0; $step < $limit; $step++ ) { + $action = array( 'next_token' ); + $kind = $walk; + if ( 'mixed' === $walk ) { + $kind = mt_rand( 0, 1 ) ? 'modify' : 'bookmarks'; + } + switch ( $kind ) { + case 'tag': + $action = array( 'next_tag' ); + break; + case 'closers': + $action = array( 'next_tag', array( 'tag_closers' => 'visit' ) ); + break; + case 'query': + $queries = array( + 'div', + 'A', + array( 'class_name' => 'x' ), + array( + 'tag_name' => 'p', + 'match_offset' => 2, + ), + array( + 'tag_name' => 'script', + 'tag_closers' => 'visit', + ), + ); + $action = array( 'next_tag', $queries[ mt_rand( 0, count( $queries ) - 1 ) ] ); + break; + case 'modify': + $roll = mt_rand( 0, 99 ); + if ( null !== $snapshot && '#tag' === $snapshot['type'] && $roll < 30 ) { + $names = array_keys( (array) $snapshot['attributes'] ); + switch ( $roll % 5 ) { + case 0: + $action = array( 'set_attribute', 'data-x', mt_rand( 0, 1 ) ? 'a"b&c<d' : true ); + break; + case 1: + $action = array( 'remove_attribute', empty( $names ) ? 'id' : $names[ mt_rand( 0, count( $names ) - 1 ) ] ); + break; + case 2: + $action = array( 'add_class', 'x' ); + break; + case 3: + $action = array( 'remove_class', empty( $snapshot['classes'] ) ? 'x' : $snapshot['classes'][0] ); + break; + default: + $action = array( 'set_modifiable_text', 'new </script> & <!-- text' ); + } + } elseif ( null !== $snapshot && $roll < 40 && '#tag' !== $snapshot['type'] ) { + $action = array( 'set_modifiable_text', 'x & y' ); + } elseif ( $roll < 43 ) { + $action = array( 'get_updated_html' ); + } + break; + case 'bookmarks': + $roll = mt_rand( 0, 99 ); + if ( $roll < 10 ) { + $name = 'b' . mt_rand( 0, 4 ); + $action = array( 'set_bookmark', $name ); + $bookmarks[ $name ] = true; + } elseif ( $roll < 13 && ! empty( $bookmarks ) && $seeks < 100 ) { + $names = array_keys( $bookmarks ); + $action = array( 'seek', $names[ mt_rand( 0, count( $names ) - 1 ) ] ); + ++$seeks; + } elseif ( $roll < 15 && ! empty( $bookmarks ) ) { + $names = array_keys( $bookmarks ); + $name = $names[ mt_rand( 0, count( $names ) - 1 ) ]; + $action = array( 'release_bookmark', $name ); + unset( $bookmarks[ $name ] ); + } + break; + } + + $outcome_a = difftrace_apply( $a, $action ); + $outcome_b = difftrace_apply( $b, $action ); + $snapshot = difftrace_snapshot( $a ); + $state_a = array( $outcome_a, $snapshot ); + $state_b = array( $outcome_b, difftrace_snapshot( $b ) ); + ++$steps; + if ( $state_a !== $state_b ) { + return array( + 'walk' => $walk, + 'step' => $step, + 'action' => $action, + 'base' => $state_a, + 'head' => $state_b, + ); + } + if ( 'next_token' === $action[0] || 'next_tag' === $action[0] ) { + if ( false === $outcome_a['return'] ) { + break; + } + } + } + + $final_a = difftrace_apply( $a, array( 'get_updated_html' ) ); + $final_b = difftrace_apply( $b, array( 'get_updated_html' ) ); + if ( $final_a !== $final_b ) { + return array( + 'walk' => $walk, + 'step' => 'final get_updated_html()', + 'action' => array( 'get_updated_html' ), + 'base' => $final_a, + 'head' => $final_b, + ); + } + return null; +} + +/** + * Serializes the HTML Processor's token stream for one input. + * + * With `$modify`, it also sets an attribute on some opening tags and calls + * get_updated_html() on some tokens (on every token of a small input, at most + * 20 times per input), which makes the processor parse the current token + * again, and records what those calls return. + * + * @param string $html Input. + * @param bool $modify Whether to modify while walking. + * @return string[] One line per token, then the final state. + */ +function difftrace_html_stream( string $html, bool $modify = false ): array { + $lines = array(); + $p = WP_HTML_Processor::create_full_parser( $html ); + if ( null === $p ) { + return array( 'create_full_parser returned null' ); + } + mt_srand( crc32( $html ), MT_RAND_MT19937 ); + $flushes = 0; + // Small inputs set an attribute on half the opening tags and flush on every token, so that a flush + // lands on virtual tokens and after errors; large inputs do both rarely. + $small = strlen( $html ) < 2000; + $set_below = $small ? 50 : 20; + while ( $p->next_token() ) { + if ( $modify ) { + // Flush before modifying, so that an update set on one token is still pending at the next. + $roll = mt_rand( 0, 99 ); + if ( ( $small || ( $roll >= 20 && $roll < 25 ) ) && ++$flushes <= 20 ) { + // Each call copies and reparses the whole document; a cap keeps large inputs linear. + $lines[] = 'get_updated_html ' . sha1( $p->get_updated_html() ); + } + if ( $roll < $set_below && '#tag' === $p->get_token_type() && ! $p->is_tag_closer() ) { + $lines[] = 'set_attribute ' . var_export( $p->set_attribute( 'data-x', 'y' ), true ); + } + } + $attributes = array(); + foreach ( (array) $p->get_attribute_names_with_prefix( '' ) as $name ) { + $attributes[] = $name . '=' . var_export( $p->get_attribute( $name ), true ); + } + $lines[] = json_encode( + array( + $p->get_token_type(), + $p->get_token_name(), + $p->is_tag_closer(), + $p->get_namespace(), + implode( ' ', (array) $p->get_breadcrumbs() ), + $attributes, + $p->get_modifiable_text(), + ), + JSON_INVALID_UTF8_SUBSTITUTE + ); + } + $exception = $p->get_unsupported_exception(); + $lines[] = 'end ' . var_export( $p->get_last_error(), true ) . ' ' . ( null === $exception ? '' : $exception->getMessage() ) . ' ' . var_export( $p->paused_at_incomplete_token(), true ); + return $lines; +} + +/** + * Runs this script in a child process for one checkout and returns its stdout lines. + * + * @param string $checkout Checkout root. + * @param array $options Options. + * @param string[] $extra Extra arguments. + * @return string[] Lines. + */ +function difftrace_child( string $checkout, array $options, array $extra ): array { + $command = array_merge( + array( PHP_BINARY, '-d', 'memory_limit=-1', __FILE__, '--seed', (string) $options['seed'], '--mutations', (string) $options['mutations'] ), + $extra + ); + // Stderr is left out of the spec so the child inherits it: passing STDERR makes PHP seek the shared offset. + $process = proc_open( $command, array( 1 => array( 'pipe', 'w' ) ), $pipes ); + $output = stream_get_contents( $pipes[1] ); + fclose( $pipes[1] ); + if ( 0 !== proc_close( $process ) ) { + fwrite( STDERR, "difftrace.php: the child for {$checkout} failed\n" ); + exit( 2 ); + } + return explode( "\n", rtrim( $output, "\n" ) ); +} + +$options = difftrace_options( $argv ); +$seed = (int) $options['seed']; + +// Child modes: load one checkout and print the HTML Processor stream. +if ( isset( $options['emit-html'] ) ) { + Benchmark_Bootstrap::load( $options['emit-html'] ); + foreach ( difftrace_inputs( $seed, (int) $options['mutations'] ) as $id => $html ) { + echo $id, "\t", sha1( implode( "\n", array_merge( difftrace_html_stream( $html ), array( 'modified' ), difftrace_html_stream( $html, true ) ) ) ), "\n"; + } + exit( 0 ); +} +if ( isset( $options['emit-html-detail'] ) ) { + list( $checkout, $wanted ) = explode( "\t", $options['emit-html-detail'], 2 ); + Benchmark_Bootstrap::load( $checkout ); + foreach ( difftrace_inputs( $seed, (int) $options['mutations'] ) as $id => $html ) { + if ( $id === $wanted ) { + echo implode( "\n", array_merge( difftrace_html_stream( $html ), array( 'modified' ), difftrace_html_stream( $html, true ) ) ), "\n"; + exit( 0 ); + } + } + exit( 1 ); +} + +if ( ! isset( $options['base'], $options['head'] ) ) { + fwrite( STDERR, "usage: php difftrace.php --base <checkout> --head <checkout> [--mutations <n>] [--seed <n>] [--tag-only|--html-only]\n" ); + exit( 2 ); +} +$base = rtrim( $options['base'], '/' ); +$head = rtrim( $options['head'], '/' ); + +$failures = 0; +$started = microtime( true ); + +$other_changes = array(); +foreach ( glob( "{$base}/src/wp-includes/html-api/*.php" ) as $path ) { + $file = basename( $path ); + if ( 'class-wp-html-tag-processor.php' !== $file && file_get_contents( $path ) !== @file_get_contents( "{$head}/src/wp-includes/html-api/{$file}" ) ) { + $other_changes[] = $file; + } +} +if ( ! empty( $other_changes ) ) { + fwrite( STDERR, 'Note: head also changes ' . implode( ', ', $other_changes ) . "; the Tag Processor check loads those files from base, the HTML Processor check covers them.\n" ); +} + +if ( ! isset( $options['html-only'] ) ) { + Benchmark_Bootstrap::load( $base ); + difftrace_load_renamed( $base, 'WP_HTML_Tag_Processor_Base' ); + difftrace_load_renamed( $head, 'WP_HTML_Tag_Processor_Head' ); + + $inputs = 0; + $steps = 0; + $shown = 0; + foreach ( difftrace_inputs( $seed, (int) $options['mutations'] ) as $id => $html ) { + ++$inputs; + foreach ( array( 'token', 'tag', 'closers', 'query', 'modify', 'bookmarks', 'mixed' ) as $walk ) { + $difference = difftrace_tag_walk( $id, $html, $walk, $seed, $steps ); + if ( null === $difference ) { + continue; + } + ++$failures; + if ( $shown++ < 5 ) { + echo "Tag Processor differs: {$id}, walk {$difference['walk']}, step {$difference['step']}\n"; + if ( strlen( $html ) <= 300 ) { + echo ' input: ', var_export( $html, true ), "\n"; + } + echo ' action: ', json_encode( $difference['action'], JSON_INVALID_UTF8_SUBSTITUTE ), "\n"; + echo ' base: ', json_encode( $difference['base'], JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR ), "\n"; + echo ' head: ', json_encode( $difference['head'], JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR ), "\n"; + } + } + } + printf( "Tag Processor: %d inputs, 7 walks each, %d steps compared, %d walks differ.\n", $inputs, $steps, $failures ); +} + +if ( ! isset( $options['tag-only'] ) ) { + $base_lines = difftrace_child( $base, $options, array( '--emit-html', $base ) ); + $head_lines = difftrace_child( $head, $options, array( '--emit-html', $head ) ); + $differing = array(); + foreach ( $base_lines as $n => $line ) { + if ( ( $head_lines[ $n ] ?? null ) !== $line ) { + $differing[] = explode( "\t", $line )[0]; + } + } + foreach ( array_slice( $differing, 0, 5 ) as $id ) { + $a = difftrace_child( $base, $options, array( '--emit-html-detail', "{$base}\t{$id}" ) ); + $b = difftrace_child( $head, $options, array( '--emit-html-detail', "{$head}\t{$id}" ) ); + $n = 0; + while ( $n < max( count( $a ), count( $b ) ) && ( $a[ $n ] ?? null ) === ( $b[ $n ] ?? null ) ) { + ++$n; + } + echo "HTML Processor differs: {$id}, token {$n}\n"; + echo ' base: ', $a[ $n ] ?? '(none)', "\n"; + echo ' head: ', $b[ $n ] ?? '(none)', "\n"; + } + $failures += count( $differing ); + printf( "HTML Processor: %d inputs, %d differ.\n", count( $base_lines ), count( $differing ) ); +} + +printf( "%s in %.0f s.\n", 0 === $failures ? 'No differences' : 'DIFFERENCES FOUND', microtime( true ) - $started ); +exit( 0 === $failures ? 0 : 1 ); diff --git a/tests/performance/html-api/fetch-corpus.php b/tests/performance/html-api/fetch-corpus.php new file mode 100644 index 0000000000000..08ffc16a315f4 --- /dev/null +++ b/tests/performance/html-api/fetch-corpus.php @@ -0,0 +1,581 @@ +<?php +/** + * Fetches the real-document corpus for the HTML API parsing benchmark. + * + * Reads corpus/manifest.json, downloads each document with curl into + * corpus/real/<id>.html, and verifies the sha256 of the bytes as served + * (after transfer decoding, no re-encoding). + * + * Usage: + * php fetch-corpus.php [--out <dir>] [--jobs <n>] [--include-optional] [--force] + * [--update-manifest] [--check-parse] [--manifest <file>] + * + * Options: + * --out <dir> Output directory. Default: corpus/real next to this script. + * --jobs <n> Parallel curl processes. Default: 4. + * --include-optional Also fetch entries marked "optional": true. + * --force Refetch entries whose file is already present and correct. + * --update-manifest Refetch everything and rewrite bytes, sha256, fetched + * (and bails, utf8 via --check-parse) in the manifest. + * --check-parse Run WP_HTML_Processor::create_full_parser() over each + * document from this checkout and report whether it bails. + * Implied by --update-manifest. + * --manifest <file> Manifest path. Default: corpus/manifest.json next to this script. + * + * A sha256 mismatch on a "pinned": true entry is an error: the file is removed + * and the exit code is non-zero. A mismatch on a "pinned": false entry is a + * warning: the file is kept and the new hash is printed. + * + * @package WordPress + * @subpackage Performance + */ + +declare( strict_types=1 ); + +if ( 'cli' !== PHP_SAPI ) { + fwrite( STDERR, "This script runs from the command line only.\n" ); + exit( 1 ); +} + +const FETCH_CORPUS_USER_AGENT = 'wordpress-develop html-api benchmark corpus fetch (+https://github.com/WordPress/wordpress-develop)'; + +/** + * Parses command-line options of the forms --name, --name=value and --name value. + * + * @param string[] $argv Raw arguments. + * @param string[] $flags Option names that take no value. + * @return array<string, string|bool> Options. + */ +function fetch_corpus_parse_args( array $argv, array $flags ): array { + $options = array(); + $count = count( $argv ); + for ( $i = 1; $i < $count; $i++ ) { + $arg = $argv[ $i ]; + if ( 0 !== strpos( $arg, '--' ) ) { + fwrite( STDERR, "Unexpected argument: {$arg}\n" ); + exit( 2 ); + } + $arg = substr( $arg, 2 ); + if ( false !== strpos( $arg, '=' ) ) { + list( $name, $value ) = explode( '=', $arg, 2 ); + $options[ $name ] = $value; + continue; + } + if ( in_array( $arg, $flags, true ) ) { + $options[ $arg ] = true; + continue; + } + if ( $i + 1 >= $count ) { + fwrite( STDERR, "Option --{$arg} needs a value.\n" ); + exit( 2 ); + } + $options[ $arg ] = $argv[ ++$i ]; + } + return $options; +} + +/** + * Finds the checkout root by walking up from this script to the directory + * containing src/wp-includes/html-api. + * + * @return string|null Absolute path or null when not found. + */ +function fetch_corpus_find_checkout_root(): ?string { + $dir = __DIR__; + while ( true ) { + if ( is_dir( $dir . '/src/wp-includes/html-api' ) ) { + return $dir; + } + $parent = dirname( $dir ); + if ( $parent === $dir ) { + return null; + } + $dir = $parent; + } +} + +/** + * Loads the HTML API from a checkout root with stubs for the WordPress + * functions it calls. + * + * @param string $root Checkout root. + */ +function fetch_corpus_load_html_api( string $root ): void { + if ( ! function_exists( '__' ) ) { + /** + * Identity translation stub. + * + * @param string $text Text. + * @return string Same text. + */ + function __( $text ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore + return $text; + } + } + if ( ! function_exists( '_doing_it_wrong' ) ) { + /** + * No-op stub. + * + * @param string $function_name Function. + * @param string $message Message. + * @param string $version Version. + */ + function _doing_it_wrong( $function_name, $message, $version ) {} // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + } + if ( ! function_exists( '_deprecated_argument' ) ) { + /** + * No-op stub. + * + * @param string $function_name Function. + * @param string $version Version. + * @param string $message Message. + */ + function _deprecated_argument( $function_name, $version, $message = '' ) {} // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + } + if ( ! function_exists( 'wp_trigger_error' ) ) { + /** + * No-op stub. + * + * @param string $function_name Function. + * @param string $message Message. + * @param int $error_level Level. + */ + function wp_trigger_error( $function_name, $message, $error_level = E_USER_NOTICE ) {} // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + } + if ( ! function_exists( 'esc_url' ) ) { + /** + * Identity stub. + * + * @param string $url URL. + * @param mixed $protocols Unused. + * @param string $_context Unused. + * @return string Same URL. + */ + function esc_url( $url, $protocols = null, $_context = 'display' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return (string) $url; + } + } + if ( ! function_exists( 'wp_kses_uri_attributes' ) ) { + /** + * The usual list of URI attributes. + * + * @return string[] Attribute names. + */ + function wp_kses_uri_attributes() { + return array( 'action', 'archive', 'background', 'cite', 'classid', 'codebase', 'data', 'formaction', 'href', 'icon', 'longdesc', 'manifest', 'poster', 'profile', 'src', 'usemap', 'xmlns' ); + } + } + + $required = array( + 'src/wp-includes/compat.php', + 'src/wp-includes/class-wp-token-map.php', + 'src/wp-includes/html-api/html5-named-character-references.php', + 'src/wp-includes/html-api/class-wp-html-attribute-token.php', + 'src/wp-includes/html-api/class-wp-html-span.php', + 'src/wp-includes/html-api/class-wp-html-doctype-info.php', + 'src/wp-includes/html-api/class-wp-html-text-replacement.php', + 'src/wp-includes/html-api/class-wp-html-decoder.php', + 'src/wp-includes/html-api/class-wp-html-tag-processor.php', + 'src/wp-includes/html-api/class-wp-html-unsupported-exception.php', + 'src/wp-includes/html-api/class-wp-html-active-formatting-elements.php', + 'src/wp-includes/html-api/class-wp-html-open-elements.php', + 'src/wp-includes/html-api/class-wp-html-token.php', + 'src/wp-includes/html-api/class-wp-html-stack-event.php', + 'src/wp-includes/html-api/class-wp-html-processor-state.php', + 'src/wp-includes/html-api/class-wp-html-processor.php', + ); + $optional = array( + 'src/wp-includes/compat-utf8.php', + 'src/wp-includes/utf8.php', + ); + + require_once $root . '/' . array_shift( $required ); + foreach ( $optional as $file ) { + if ( file_exists( $root . '/' . $file ) ) { + require_once $root . '/' . $file; + } + } + if ( ! function_exists( 'wp_scrub_utf8' ) ) { + /** + * Identity stub for checkouts without utf8.php. + * + * @param string $text Text. + * @return string Same text. + */ + function wp_scrub_utf8( $text ) { + return $text; + } + } + foreach ( $required as $file ) { + $path = $root . '/' . $file; + if ( ! file_exists( $path ) ) { + fwrite( STDERR, "Missing HTML API file in checkout: {$path}\n" ); + exit( 1 ); + } + require_once $path; + } +} + +/** + * Runs the HTML Processor over a document and reports whether it bailed. + * + * @param string $html Document. + * @return string|null Bail reason or null when the parse reached the end. + */ +function fetch_corpus_check_parse( string $html ): ?string { + if ( method_exists( 'WP_HTML_Processor', 'create_full_parser' ) ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + } else { + $processor = WP_HTML_Processor::create_fragment( $html ); + } + if ( null === $processor ) { + return 'create_full_parser() returned null'; + } + while ( $processor->next_token() ) { + // Parse to the end. + } + $error = $processor->get_last_error(); + if ( null === $error ) { + return null; + } + $exception = $processor->get_unsupported_exception(); + if ( null !== $exception ) { + return $error . ': ' . $exception->getMessage(); + } + return $error; +} + +/** + * Starts a curl process for one manifest entry. + * + * @param array $doc Manifest entry. + * @param string $tmp_path Download target. + * @return array{proc: resource, pipes: array, out: string, err: string} + */ +function fetch_corpus_start_curl( array $doc, string $tmp_path ): array { + $command = array( + 'curl', + '--silent', + '--show-error', + '--compressed', + '--location', + '--max-time', + '300', + '--user-agent', + FETCH_CORPUS_USER_AGENT, + '--output', + $tmp_path, + '--write-out', + '%{http_code}\t%{url_effective}', + '--', + $doc['url'], + ); + $spec = array( + 0 => array( 'file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + $pipes = array(); + $proc = proc_open( $command, $spec, $pipes ); + if ( ! is_resource( $proc ) ) { + fwrite( STDERR, "Could not start curl for {$doc['id']}.\n" ); + exit( 1 ); + } + stream_set_blocking( $pipes[1], false ); + stream_set_blocking( $pipes[2], false ); + return array( + 'proc' => $proc, + 'pipes' => $pipes, + 'out' => '', + 'err' => '', + ); +} + +/** + * Fetches a set of manifest entries in parallel. + * + * @param array $docs Manifest entries keyed by id. + * @param string $out Output directory. + * @param int $jobs Parallel processes. + * @return array<string, array{code: int, final_url: string, error: string|null, path: string}> Results keyed by id. + */ +function fetch_corpus_fetch_all( array $docs, string $out, int $jobs ): array { + $queue = array_values( $docs ); + $running = array(); + $results = array(); + + while ( count( $queue ) > 0 || count( $running ) > 0 ) { + while ( count( $running ) < $jobs && count( $queue ) > 0 ) { + $doc = array_shift( $queue ); + $tmp = $out . '/' . $doc['id'] . '.html.part'; + $job = fetch_corpus_start_curl( $doc, $tmp ); + $job['doc'] = $doc; + $job['tmp'] = $tmp; + $running[ $doc['id'] ] = $job; + fwrite( STDERR, "fetching {$doc['id']}: {$doc['url']}\n" ); + } + + foreach ( $running as $id => &$job ) { + $job['out'] .= (string) stream_get_contents( $job['pipes'][1] ); + $job['err'] .= (string) stream_get_contents( $job['pipes'][2] ); + $status = proc_get_status( $job['proc'] ); + if ( $status['running'] ) { + continue; + } + $job['out'] .= (string) stream_get_contents( $job['pipes'][1] ); + $job['err'] .= (string) stream_get_contents( $job['pipes'][2] ); + fclose( $job['pipes'][1] ); + fclose( $job['pipes'][2] ); + proc_close( $job['proc'] ); + + $parts = explode( "\t", trim( $job['out'] ), 2 ); + $code = (int) $parts[0]; + $final_url = $parts[1] ?? ''; + $error = null; + if ( 0 !== $status['exitcode'] ) { + $error = 'curl exit ' . $status['exitcode'] . ': ' . trim( $job['err'] ); + } elseif ( 200 !== $code ) { + $error = "HTTP {$code}"; + } + $results[ $id ] = array( + 'code' => $code, + 'final_url' => $final_url, + 'error' => $error, + 'path' => $job['tmp'], + ); + unset( $running[ $id ] ); + } + unset( $job ); + + if ( count( $running ) > 0 ) { + usleep( 50000 ); + } + } + + return $results; +} + +$options = fetch_corpus_parse_args( $argv, array( 'include-optional', 'force', 'update-manifest', 'check-parse', 'help' ) ); + +if ( isset( $options['help'] ) ) { + fwrite( STDOUT, "Usage: php fetch-corpus.php [--out <dir>] [--jobs <n>] [--include-optional] [--force] [--update-manifest] [--check-parse] [--manifest <file>]\n" ); + exit( 0 ); +} + +$manifest_path = (string) ( $options['manifest'] ?? __DIR__ . '/corpus/manifest.json' ); +$out = rtrim( (string) ( $options['out'] ?? __DIR__ . '/corpus/real' ), '/' ); +$jobs = max( 1, (int) ( $options['jobs'] ?? 4 ) ); +$include_optional = isset( $options['include-optional'] ); +$force = isset( $options['force'] ); +$update_manifest = isset( $options['update-manifest'] ); +$check_parse = $update_manifest || isset( $options['check-parse'] ); +$today = gmdate( 'Y-m-d' ); + +$manifest_json = file_get_contents( $manifest_path ); +if ( false === $manifest_json ) { + fwrite( STDERR, "Cannot read manifest: {$manifest_path}\n" ); + exit( 1 ); +} +$manifest = json_decode( $manifest_json, true ); +if ( ! is_array( $manifest ) || ! isset( $manifest['documents'] ) || ! is_array( $manifest['documents'] ) ) { + fwrite( STDERR, "Manifest is not valid JSON with a \"documents\" array: {$manifest_path}\n" ); + exit( 1 ); +} + +if ( ! is_dir( $out ) && ! mkdir( $out, 0777, true ) && ! is_dir( $out ) ) { + fwrite( STDERR, "Cannot create output directory: {$out}\n" ); + exit( 1 ); +} + +$checkout_root = null; +if ( $check_parse ) { + $checkout_root = fetch_corpus_find_checkout_root(); + if ( null === $checkout_root ) { + fwrite( STDERR, "Cannot find a checkout root (a directory containing src/wp-includes/html-api) above {$manifest_path}.\n" ); + exit( 1 ); + } + ini_set( 'memory_limit', '2G' ); + fetch_corpus_load_html_api( $checkout_root ); +} + +/* + * Decide what to fetch. + */ +$rows = array(); +$to_fetch = array(); +$exit_code = 0; + +foreach ( $manifest['documents'] as $index => $doc ) { + $id = (string) $doc['id']; + $path = $out . '/' . $id . '.html'; + + if ( ! empty( $doc['optional'] ) && ! $include_optional ) { + $rows[ $id ] = array( + 'bytes' => $doc['bytes'] ?? null, + 'status' => 'skipped (optional)', + ); + continue; + } + + if ( ! $force && ! $update_manifest && file_exists( $path ) && ! empty( $doc['sha256'] ) ) { + if ( hash_file( 'sha256', $path ) === $doc['sha256'] ) { + $rows[ $id ] = array( + 'bytes' => filesize( $path ), + 'status' => 'present', + ); + continue; + } + } + + $to_fetch[ $id ] = $doc; +} + +$results = fetch_corpus_fetch_all( $to_fetch, $out, $jobs ); + +/* + * Verify each fetched file against the manifest and, with --update-manifest, + * rewrite the entry. + */ +foreach ( $manifest['documents'] as $index => &$doc ) { + $id = (string) $doc['id']; + if ( ! isset( $results[ $id ] ) ) { + continue; + } + $result = $results[ $id ]; + $path = $out . '/' . $id . '.html'; + $pinned = ! empty( $doc['pinned'] ); + + if ( null !== $result['error'] ) { + if ( file_exists( $result['path'] ) ) { + unlink( $result['path'] ); + } + $rows[ $id ] = array( + 'bytes' => null, + 'status' => 'error: ' . $result['error'], + ); + $exit_code = 1; + continue; + } + + $bytes = filesize( $result['path'] ); + $sha256 = hash_file( 'sha256', $result['path'] ); + + if ( 0 === $bytes ) { + unlink( $result['path'] ); + $rows[ $id ] = array( + 'bytes' => 0, + 'status' => 'error: empty response', + ); + $exit_code = 1; + continue; + } + + $expected = $doc['sha256'] ?? null; + $matches = empty( $expected ) || $expected === $sha256; + + if ( $update_manifest ) { + if ( $pinned && ! $matches ) { + fwrite( STDERR, "warning: pinned {$id} changed since {$doc['fetched']}: {$expected} -> {$sha256}\n" ); + } + $doc['bytes'] = $bytes; + $doc['sha256'] = $sha256; + if ( ! $pinned || ! $matches || empty( $doc['fetched'] ) ) { + $doc['fetched'] = $today; + } + if ( '' !== $result['final_url'] && $result['final_url'] !== $doc['url'] ) { + $doc['final_url'] = $result['final_url']; + } else { + unset( $doc['final_url'] ); + } + rename( $result['path'], $path ); + $rows[ $id ] = array( + 'bytes' => $bytes, + 'status' => $matches ? 'fetched' : 'fetched (hash updated)', + ); + continue; + } + + if ( $matches ) { + rename( $result['path'], $path ); + $rows[ $id ] = array( + 'bytes' => $bytes, + 'status' => 'fetched', + ); + continue; + } + + if ( $pinned ) { + unlink( $result['path'] ); + fwrite( STDERR, "error: pinned {$id} hash mismatch: expected {$expected}, got {$sha256}; file removed\n" ); + $rows[ $id ] = array( + 'bytes' => $bytes, + 'status' => 'error: pinned hash mismatch', + ); + $exit_code = 1; + continue; + } + + rename( $result['path'], $path ); + fwrite( STDERR, "warning: {$id} drifted since {$doc['fetched']}: manifest {$expected}, fetched {$sha256}; file kept\n" ); + $rows[ $id ] = array( + 'bytes' => $bytes, + 'status' => 'fetched (drifted since ' . $doc['fetched'] . ')', + ); +} +unset( $doc ); + +/* + * Parse check. + */ +if ( $check_parse ) { + foreach ( $manifest['documents'] as &$doc ) { + $id = (string) $doc['id']; + $path = $out . '/' . $id . '.html'; + if ( ! file_exists( $path ) || 0 === strpos( $rows[ $id ]['status'] ?? '', 'error' ) || 0 === strpos( $rows[ $id ]['status'] ?? '', 'skipped' ) ) { + continue; + } + $html = (string) file_get_contents( $path ); + $utf8 = 1 === preg_match( '//u', $html ); + $start = hrtime( true ); + $bails = fetch_corpus_check_parse( $html ); + $ms = (int) round( ( hrtime( true ) - $start ) / 1e6 ); + $rows[ $id ]['utf8'] = $utf8; + $rows[ $id ]['bails'] = $bails; + fwrite( STDERR, sprintf( "parsed %s in %d ms: %s\n", $id, $ms, null === $bails ? 'ok' : 'bails: ' . $bails ) ); + if ( $update_manifest ) { + $doc['utf8'] = $utf8; + $doc['bails'] = $bails; + } + } + unset( $doc ); +} + +if ( $update_manifest ) { + $manifest['generated'] = $today; + $json = json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if ( false === $json || false === file_put_contents( $manifest_path, $json . "\n" ) ) { + fwrite( STDERR, "Cannot write manifest: {$manifest_path}\n" ); + exit( 1 ); + } +} + +/* + * Summary table. + */ +$total_bytes = 0; +$id_width = max( 2, ...array_map( 'strlen', array_keys( $rows ) ) ); +fwrite( STDOUT, sprintf( "%-{$id_width}s %10s %s\n", 'id', 'bytes', $check_parse ? 'status (utf8, bails)' : 'status' ) ); +foreach ( $rows as $id => $row ) { + $bytes = $row['bytes']; + if ( is_int( $bytes ) && 0 !== strpos( $row['status'], 'skipped' ) && 0 !== strpos( $row['status'], 'error' ) ) { + $total_bytes += $bytes; + } + $extra = ''; + if ( $check_parse && array_key_exists( 'bails', $row ) ) { + $extra = sprintf( ' (utf8: %s, bails: %s)', $row['utf8'] ? 'yes' : 'no', null === $row['bails'] ? 'no' : $row['bails'] ); + } + fwrite( STDOUT, sprintf( "%-{$id_width}s %10s %s%s\n", $id, null === $bytes ? '-' : number_format( (int) $bytes ), $row['status'], $extra ) ); +} +fwrite( STDOUT, sprintf( "%-{$id_width}s %10s on disk\n", 'total', number_format( $total_bytes ) ) ); + +exit( $exit_code ); diff --git a/tests/performance/html-api/lib/class-benchmark-bootstrap.php b/tests/performance/html-api/lib/class-benchmark-bootstrap.php new file mode 100644 index 0000000000000..689c29816c902 --- /dev/null +++ b/tests/performance/html-api/lib/class-benchmark-bootstrap.php @@ -0,0 +1,218 @@ +<?php +/** + * Loads the HTML API from a WordPress checkout without loading WordPress. + * + * Part of the HTML API parsing benchmark. Dev tool only; not shipped. + * + * @package WordPress + * @subpackage HTML-API + */ + +/** + * Loads the HTML API files from a checkout root plus the WordPress function + * stubs they call at runtime. + */ +class Benchmark_Bootstrap { + /** + * Files required from the checkout root, in load order. + * + * Entries flagged optional are skipped when absent: older trunks lack them. + * + * @var array<string, bool> Path relative to the checkout root => whether optional. + */ + const FILES = array( + 'src/wp-includes/compat.php' => false, + 'src/wp-includes/compat-utf8.php' => true, + 'src/wp-includes/utf8.php' => true, + 'src/wp-includes/class-wp-token-map.php' => false, + 'src/wp-includes/html-api/html5-named-character-references.php' => false, + 'src/wp-includes/html-api/class-wp-html-attribute-token.php' => false, + 'src/wp-includes/html-api/class-wp-html-span.php' => false, + 'src/wp-includes/html-api/class-wp-html-doctype-info.php' => false, + 'src/wp-includes/html-api/class-wp-html-text-replacement.php' => false, + 'src/wp-includes/html-api/class-wp-html-decoder.php' => false, + 'src/wp-includes/html-api/class-wp-html-tag-processor.php' => false, + 'src/wp-includes/html-api/class-wp-html-unsupported-exception.php' => false, + 'src/wp-includes/html-api/class-wp-html-active-formatting-elements.php' => false, + 'src/wp-includes/html-api/class-wp-html-open-elements.php' => false, + 'src/wp-includes/html-api/class-wp-html-token.php' => false, + 'src/wp-includes/html-api/class-wp-html-stack-event.php' => false, + 'src/wp-includes/html-api/class-wp-html-processor-state.php' => false, + 'src/wp-includes/html-api/class-wp-html-processor.php' => false, + ); + + /** + * Loads the HTML API from the given checkout. + * + * @throws RuntimeException When a required file is missing. + * + * @param string $checkout_root Directory containing `src/wp-includes/html-api`. + */ + public static function load( string $checkout_root ): void { + $checkout_root = rtrim( $checkout_root, '/' ); + + if ( ! is_dir( "{$checkout_root}/src/wp-includes/html-api" ) ) { + throw new RuntimeException( "Not a WordPress checkout (no src/wp-includes/html-api): {$checkout_root}" ); + } + + self::define_stubs(); + + if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', "{$checkout_root}/src/" ); + } + if ( ! defined( 'WPINC' ) ) { + define( 'WPINC', 'wp-includes' ); + } + + foreach ( self::FILES as $relative_path => $is_optional ) { + $path = "{$checkout_root}/{$relative_path}"; + if ( ! file_exists( $path ) ) { + if ( $is_optional ) { + continue; + } + throw new RuntimeException( "Required file missing from checkout: {$path}" ); + } + require_once $path; + } + + self::define_late_stubs(); + } + + /** + * Defines the WordPress functions the HTML API calls, when not already defined. + * + * Mirrors the fuzz harness stubs in tools/html-api-fuzz/lib/wp-stubs.php. + */ + private static function define_stubs(): void { + if ( ! function_exists( '__' ) ) { + /** + * Identity translation stub. + * + * @param string $text Text. + * @param string $domain Unused. + * @return string Text. + */ + function __( $text, $domain = 'default' ) { // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText + return $text; + } + } + + if ( ! function_exists( '_doing_it_wrong' ) ) { + /** + * No-op stub. + * + * @param string $function_name Unused. + * @param string $message Unused. + * @param string $version Unused. + */ + function _doing_it_wrong( $function_name, $message, $version ) {} + } + + if ( ! function_exists( '_deprecated_argument' ) ) { + /** + * No-op stub. + * + * @param string $function_name Unused. + * @param string $version Unused. + * @param string $message Unused. + */ + function _deprecated_argument( $function_name, $version, $message = '' ) {} + } + + if ( ! function_exists( '_deprecated_function' ) ) { + /** + * No-op stub. + * + * @param string $function_name Unused. + * @param string $version Unused. + * @param string $replacement Unused. + */ + function _deprecated_function( $function_name, $version, $replacement = '' ) {} + } + + if ( ! function_exists( 'wp_trigger_error' ) ) { + /** + * No-op stub. + * + * @param string $function_name Unused. + * @param string $message Unused. + * @param int $error_level Unused. + */ + function wp_trigger_error( $function_name, $message, $error_level = E_USER_NOTICE ) {} + } + + if ( ! function_exists( 'esc_url' ) ) { + /** + * Identity stub. + * + * @param string $url URL. + * @param array $protocols Unused. + * @param string $_context Unused. + * @return string URL unchanged. + */ + function esc_url( $url, $protocols = null, $_context = 'display' ) { + return (string) $url; + } + } + + if ( ! function_exists( 'wp_kses_uri_attributes' ) ) { + /** + * The core list of URI attributes. + * + * @return string[] Attribute names. + */ + function wp_kses_uri_attributes() { + return array( + 'action', + 'archive', + 'background', + 'cite', + 'classid', + 'codebase', + 'data', + 'formaction', + 'href', + 'icon', + 'longdesc', + 'manifest', + 'poster', + 'profile', + 'src', + 'usemap', + 'xmlns', + ); + } + } + } + + /** + * Defines stubs for functions newer checkouts define in utf8.php. + * + * Runs after the checkout's files load so a real definition wins. + */ + private static function define_late_stubs(): void { + if ( ! function_exists( 'wp_scrub_utf8' ) ) { + /** + * Identity stub for checkouts without utf8.php. + * + * @param string $text Text. + * @return string Text unchanged. + */ + function wp_scrub_utf8( $text ) { + return $text; + } + } + + if ( ! function_exists( 'wp_has_noncharacters' ) ) { + /** + * Stub for checkouts without utf8.php: reports no noncharacters. + * + * @param string $text Unused. + * @return bool False. + */ + function wp_has_noncharacters( string $text ): bool { + return false; + } + } + } +} diff --git a/tests/performance/html-api/lib/class-benchmark-lock.php b/tests/performance/html-api/lib/class-benchmark-lock.php new file mode 100644 index 0000000000000..48f72efc81886 --- /dev/null +++ b/tests/performance/html-api/lib/class-benchmark-lock.php @@ -0,0 +1,51 @@ +<?php +/** + * Machine-wide lock held for the whole of a timed run. + * + * Part of the HTML API parsing benchmark. Dev tool only; not shipped. + * + * @package WordPress + * @subpackage HTML-API + */ + +/** + * Serializes timed runs on one machine: two runs at once slow each other and + * corrupt both results. `bench.php` and `with-lock.php` hold the same lock file. + */ +class Benchmark_Lock { + /** + * Path of the lock file. A fixed path so that every user session and process sees the same file. + * + * @return string Path. + */ + public static function path(): string { + $dir = is_dir( '/tmp' ) ? '/tmp' : sys_get_temp_dir(); + return "{$dir}/wp-html-api-bench.lock"; + } + + /** + * Takes the lock, waiting for any other holder, and keeps it until the process exits. + * + * @param string $who Description of the holder, written into the lock file for anyone waiting. + * @return resource Lock handle; keep a reference for as long as the lock is needed. + */ + public static function acquire( string $who ) { + $path = self::path(); + $handle = fopen( $path, 'c+' ); + if ( false === $handle ) { + throw new RuntimeException( "Could not open the lock file {$path}" ); + } + if ( ! flock( $handle, LOCK_EX | LOCK_NB ) ) { + $holder = trim( (string) stream_get_contents( $handle ) ); + fwrite( STDERR, "Waiting for another timed run to finish ({$path}" . ( '' === $holder ? '' : ": {$holder}" ) . ")\n" ); + if ( ! flock( $handle, LOCK_EX ) ) { + throw new RuntimeException( "Could not lock {$path}" ); + } + } + ftruncate( $handle, 0 ); + rewind( $handle ); + fwrite( $handle, sprintf( "pid %d since %s: %s\n", getmypid(), gmdate( 'Y-m-d\TH:i:s\Z' ), $who ) ); + fflush( $handle ); + return $handle; + } +} diff --git a/tests/performance/html-api/lib/class-benchmark-report.php b/tests/performance/html-api/lib/class-benchmark-report.php new file mode 100644 index 0000000000000..a4ae58ef8d48f --- /dev/null +++ b/tests/performance/html-api/lib/class-benchmark-report.php @@ -0,0 +1,306 @@ +<?php +/** + * Report rendering for the HTML API parsing benchmark. + * + * @package WordPress + * @subpackage HTML-API + */ + +/** + * Renders a benchmark result structure as a text table, markdown, or JSON. + * + * The result structure is the one bench.php saves with `--save`; see README.md. + */ +class Benchmark_Report { + /** + * Human names for parser ids. + */ + const PARSER_NAMES = array( + 'tag' => 'WP_HTML_Tag_Processor', + 'html' => 'WP_HTML_Processor', + ); + + /** + * CV above which a row is marked noisy. + */ + const NOISY_CV = 0.10; + + /** + * Renders the result in the requested format. + * + * @param array $result Result structure. + * @param string $format 'table', 'markdown', or 'json'. + * @return string + */ + public static function render( array $result, string $format ): string { + if ( 'json' === $format ) { + return json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; + } + $markdown = 'markdown' === $format; + $out = self::render_header( $result, $markdown ); + + foreach ( array( 'tag', 'html' ) as $parser ) { + $rows = array_values( + array_filter( + $result['results'], + static function ( $row ) use ( $parser ) { + return $row['parser'] === $parser; + } + ) + ); + if ( empty( $rows ) ) { + continue; + } + $out .= "\n"; + $out .= self::heading( self::PARSER_NAMES[ $parser ] ?? $parser, $markdown ); + if ( 'single' === $result['mode'] ) { + $out .= self::render_single_table( $rows, $markdown ); + } else { + $out .= self::render_ab_table( $rows, $markdown ); + } + $out .= self::render_summary( $result['summary'][ $parser ] ?? array(), $result['mode'], $markdown ); + } + + return $out; + } + + /** + * Renders the report header. + * + * @param array $result Result structure. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function render_header( array $result, bool $markdown ): string { + $config = $result['config']; + $lines = array(); + + $lines[] = 'HTML API parsing benchmark'; + $lines[] = 'Date: ' . $result['generated']; + foreach ( array( 'base', 'head' ) as $tree ) { + if ( empty( $result['trees'][ $tree ] ) ) { + continue; + } + $info = $result['trees'][ $tree ]; + $lines[] = sprintf( + '%s: %s (%s), PHP %s, opcache %s, jit %s', + $tree, + $info['checkout'], + $info['head'] ?? 'no git head', + $info['php'], + $info['opcache'] ? 'on' : 'off', + $info['jit'] ?? 'off' + ); + } + $lines[] = sprintf( + 'Samples: %d per document, parser and tree; min sample %d ms; warmup %d parses and %d ms; rounds %d (a fresh worker pair each, shuffled order); seed %d; bootstrap resamples %d', + $config['samples'], + $config['min_sample_ms'], + $config['warmup'], + $config['warmup_ms'] ?? 0, + $config['rounds'] ?? 1, + $config['seed'], + Benchmark_Stats::RESAMPLES + ); + if ( ! empty( $config['php_args'] ) ) { + $lines[] = 'Extra PHP args: ' . $config['php_args']; + } + foreach ( $result['notes'] ?? array() as $note ) { + $lines[] = 'Note: ' . $note; + } + + if ( $markdown ) { + $first = array_shift( $lines ); + return "# {$first}\n\n" . implode( " \n", $lines ) . "\n"; + } + return implode( "\n", $lines ) . "\n"; + } + + /** + * Renders a section heading. + * + * @param string $text Heading. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function heading( string $text, bool $markdown ): string { + return $markdown ? "## {$text}\n\n" : "{$text}\n\n"; + } + + /** + * Renders the A/B table. + * + * @param array $rows Result rows for one parser. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function render_ab_table( array $rows, bool $markdown ): string { + $header = array( 'document', 'bytes', 'base ms', 'head ms', 'change', '95% CI', 'verdict', 'CV base', 'CV head' ); + $align = array( 'l', 'r', 'r', 'r', 'r', 'r', 'l', 'r', 'r' ); + $table = array(); + foreach ( $rows as $row ) { + $base = $row['trees']['base']; + $head = $row['trees']['head']; + $cmp = $row['comparison']; + $note = ''; + if ( null !== $base['bailed'] || null !== $head['bailed'] ) { + $note .= ' (bailed)'; + } + if ( $base['stats']['cv'] > self::NOISY_CV || $head['stats']['cv'] > self::NOISY_CV ) { + $note .= ' (noisy)'; + } + $table[] = array( + $row['id'], + Benchmark_Stats::format_int( $row['bytes'] ), + Benchmark_Stats::format_ms( $base['stats']['round_mean_median_ns'] ?? $base['stats']['median_ns'] ), + Benchmark_Stats::format_ms( $head['stats']['round_mean_median_ns'] ?? $head['stats']['median_ns'] ), + Benchmark_Stats::format_pct( $cmp['change_pct'] ), + Benchmark_Stats::format_ci( $cmp['ci_low'], $cmp['ci_high'] ), + $cmp['verdict'] . $note, + Benchmark_Stats::format_cv( $base['stats']['cv'] ), + Benchmark_Stats::format_cv( $head['stats']['cv'] ), + ); + } + return self::table( $header, $align, $table, $markdown ); + } + + /** + * Renders the single-tree table. + * + * @param array $rows Result rows for one parser. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function render_single_table( array $rows, bool $markdown ): string { + $header = array( 'document', 'bytes', 'tokens', 'median ms', 'MB/s', 'tokens/s', 'CV', 'peak MB' ); + // peak MB is the emalloc peak above the pre-run level; the 2 MB-chunk system peak is in the JSON as peak_bytes. + $align = array( 'l', 'r', 'r', 'r', 'r', 'r', 'r', 'r' ); + $table = array(); + foreach ( $rows as $row ) { + $head = $row['trees']['head']; + $table[] = array( + $row['id'] . ( null !== $head['bailed'] ? ' (bailed)' : '' ) . ( $head['stats']['cv'] > self::NOISY_CV ? ' (noisy)' : '' ), + Benchmark_Stats::format_int( $row['bytes'] ), + Benchmark_Stats::format_int( $head['tokens'] ), + Benchmark_Stats::format_ms( $head['stats']['median_ns'] ), + Benchmark_Stats::format_rate( $head['stats']['mb_per_s'] ), + Benchmark_Stats::format_int( $head['stats']['tokens_per_s'] ), + Benchmark_Stats::format_cv( $head['stats']['cv'] ), + Benchmark_Stats::format_mb( $head['peak_alloc_bytes'] ?? $head['peak_bytes'] ), + ); + } + return self::table( $header, $align, $table, $markdown ); + } + + /** + * Renders the per-parser summary lines and bailed list. + * + * @param array $summary Summary for one parser. + * @param string $mode Result mode. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function render_summary( array $summary, string $mode, bool $markdown ): string { + $lines = array(); + + if ( 'single' !== $mode ) { + $overall = $summary['overall'] ?? null; + if ( null === $overall ) { + $lines[] = 'Overall: no documents completed on both trees.'; + } else { + $lines[] = sprintf( + 'Overall: geomean ratio head/base %.4f (%s), 95%% CI %s, %s, over %d documents (%s).', + $overall['geomean'], + Benchmark_Stats::format_pct( $overall['change_pct'] ), + Benchmark_Stats::format_ci( $overall['ci_low'], $overall['ci_high'] ), + $overall['verdict'], + $overall['documents'], + $overall['method'] ?? 'bootstrap' + ); + if ( ! empty( $overall['round_geomeans'] ) ) { + // One slow round (a worker stuck on a slow core) widens the interval; show where it came from. + $lines[] = 'Per round: ' . implode( + ' ', + array_map( + static function ( $ratio ) { + return Benchmark_Stats::format_pct( ( $ratio - 1.0 ) * 100.0 ); + }, + $overall['round_geomeans'] + ) + ); + } + } + } + + $throughput = $summary['throughput'] ?? array(); + $parts = array(); + foreach ( array( 'base', 'head' ) as $tree ) { + if ( isset( $throughput[ $tree ] ) ) { + $parts[] = sprintf( '%s %s MB/s', $tree, Benchmark_Stats::format_rate( $throughput[ $tree ] ) ); + } + } + if ( ! empty( $parts ) ) { + $lines[] = 'Throughput (sum of bytes / sum of median ns): ' . implode( ', ', $parts ) . '.'; + } + + $bailed = $summary['bailed'] ?? array(); + if ( ! empty( $bailed ) ) { + $lines[] = 'Bailed (excluded from the aggregate):'; + foreach ( $bailed as $entry ) { + $lines[] = sprintf( ' %s on %s: %s', $entry['id'], $entry['tree'], $entry['reason'] ); + } + } + + if ( $markdown ) { + return "\n" . implode( " \n", $lines ) . "\n"; + } + return "\n" . implode( "\n", $lines ) . "\n"; + } + + /** + * Renders an aligned text table or a markdown table. + * + * @param string[] $header Column headings. + * @param string[] $align 'l' or 'r' per column. + * @param string[][] $rows Cell strings. + * @param bool $markdown Whether to render markdown. + * @return string + */ + private static function table( array $header, array $align, array $rows, bool $markdown ): string { + $widths = array_map( 'strlen', $header ); + foreach ( $rows as $row ) { + foreach ( $row as $i => $cell ) { + $widths[ $i ] = max( $widths[ $i ], strlen( $cell ) ); + } + } + + $format_row = static function ( array $cells ) use ( $widths, $align, $markdown ): string { + $out = array(); + foreach ( $cells as $i => $cell ) { + $pad = $widths[ $i ] - strlen( $cell ); + $out[] = 'r' === $align[ $i ] ? str_repeat( ' ', $pad ) . $cell : $cell . str_repeat( ' ', $pad ); + } + return $markdown ? '| ' . implode( ' | ', $out ) . ' |' : implode( ' ', $out ); + }; + + $lines = array(); + $lines[] = $format_row( $header ); + if ( $markdown ) { + $rule = array(); + foreach ( $widths as $i => $width ) { + $rule[] = 'r' === $align[ $i ] ? str_repeat( '-', $width - 1 ) . ':' : str_repeat( '-', $width ); + } + $lines[] = '| ' . implode( ' | ', $rule ) . ' |'; + } else { + $rule = array(); + foreach ( $widths as $width ) { + $rule[] = str_repeat( '-', $width ); + } + $lines[] = implode( ' ', $rule ); + } + foreach ( $rows as $row ) { + $lines[] = $format_row( $row ); + } + return implode( "\n", $lines ) . "\n"; + } +} diff --git a/tests/performance/html-api/lib/class-benchmark-stats.php b/tests/performance/html-api/lib/class-benchmark-stats.php new file mode 100644 index 0000000000000..b9251959852f3 --- /dev/null +++ b/tests/performance/html-api/lib/class-benchmark-stats.php @@ -0,0 +1,482 @@ +<?php +/** + * Statistics for the HTML API parsing benchmark. + * + * @package WordPress + * @subpackage HTML-API + */ + +/** + * Median, bootstrap confidence intervals, geometric mean, and number formatting. + * + * Every bootstrap is deterministic for a given seed: it reseeds the Mersenne + * Twister with `mt_srand( $seed, MT_RAND_MT19937 )` before resampling. + */ +class Benchmark_Stats { + /** + * Number of bootstrap resamples. + */ + const RESAMPLES = 2000; + + /** + * Median of a list of numbers. + * + * @param float[] $values Non-empty list. + * @return float + */ + public static function median( array $values ): float { + $values = array_values( $values ); + sort( $values ); + $count = count( $values ); + if ( 0 === $count ) { + return NAN; + } + $middle = intdiv( $count, 2 ); + if ( 0 === $count % 2 ) { + return ( $values[ $middle - 1 ] + $values[ $middle ] ) / 2; + } + return (float) $values[ $middle ]; + } + + /** + * Summary statistics of one list of per-parse timings. + * + * @param float[] $samples_ns Nanoseconds per parse, one entry per sample. + * @param int $bytes Document size in bytes. + * @param int $tokens Tokens per parse. + * @return array{n:int, median_ns:float, min_ns:float, mean_ns:float, stddev_ns:float, cv:float, mb_per_s:float, tokens_per_s:float} + */ + public static function summarize( array $samples_ns, int $bytes, int $tokens ): array { + $count = count( $samples_ns ); + $median = self::median( $samples_ns ); + $mean = array_sum( $samples_ns ) / max( 1, $count ); + $sumsq = 0.0; + foreach ( $samples_ns as $sample ) { + $sumsq += ( $sample - $mean ) * ( $sample - $mean ); + } + $stddev = $count > 1 ? sqrt( $sumsq / ( $count - 1 ) ) : 0.0; + + return array( + 'n' => $count, + 'median_ns' => $median, + 'min_ns' => (float) min( $samples_ns ), + 'mean_ns' => $mean, + 'stddev_ns' => $stddev, + 'cv' => $mean > 0 ? $stddev / $mean : 0.0, + 'mb_per_s' => $median > 0 ? ( $bytes * 1000.0 ) / $median : 0.0, + 'tokens_per_s' => $median > 0 ? ( $tokens * 1e9 ) / $median : 0.0, + ); + } + + /** + * Draws a resample with replacement. + * + * @param float[] $values Non-empty list. + * @return float[] Same length as the input. + */ + private static function resample( array $values ): array { + $count = count( $values ); + $last = $count - 1; + $out = array(); + for ( $i = 0; $i < $count; $i++ ) { + $out[] = $values[ mt_rand( 0, $last ) ]; + } + return $out; + } + + /** + * Percentile of a sorted list by nearest-rank interpolation. + * + * @param float[] $sorted Sorted ascending. + * @param float $p Percentile in [0, 1]. + * @return float + */ + private static function percentile( array $sorted, float $p ): float { + $count = count( $sorted ); + if ( 0 === $count ) { + return NAN; + } + $rank = $p * ( $count - 1 ); + $lower = (int) floor( $rank ); + $upper = (int) ceil( $rank ); + if ( $lower === $upper ) { + return $sorted[ $lower ]; + } + $weight = $rank - $lower; + return $sorted[ $lower ] * ( 1 - $weight ) + $sorted[ $upper ] * $weight; + } + + /** + * Verdict from a ratio confidence interval. + * + * @param float $low Lower bound of the ratio CI. + * @param float $high Upper bound of the ratio CI. + * @return string 'faster', 'slower', or 'no difference'. + */ + public static function verdict( float $low, float $high ): string { + if ( $high < 1.0 ) { + return 'faster'; + } + if ( $low > 1.0 ) { + return 'slower'; + } + return 'no difference'; + } + + /** + * Normalizes timings to a list of rounds. + * + * A flat list of numbers is one round; a list of lists is one entry per round. + * + * @param array $samples Flat list or list of lists. + * @return float[][] Rounds. + */ + private static function rounds( array $samples ): array { + $samples = array_values( $samples ); + if ( empty( $samples ) || ! is_array( $samples[0] ) ) { + return array( $samples ); + } + return array_map( 'array_values', $samples ); + } + + /** + * Point estimate of the head/base ratio: geometric mean over rounds of the ratio of medians. + * + * @param float[][] $head_rounds Head timings per round. + * @param float[][] $base_rounds Base timings per round, same length. + * @return float + */ + private static function round_ratio( array $head_rounds, array $base_rounds ): float { + $count = count( $head_rounds ); + $log_sum = 0.0; + for ( $r = 0; $r < $count; $r++ ) { + $log_sum += log( self::median( $head_rounds[ $r ] ) / self::median( $base_rounds[ $r ] ) ); + } + return exp( $log_sum / $count ); + } + + /** + * One bootstrap draw of the head/base ratio for one document. + * + * Rounds are drawn with replacement as head/base pairs (the same round index + * on both trees shares a process pair and a time window); within each drawn + * round, head and base samples are drawn with replacement. + * + * @param float[][] $head_rounds Head timings per round. + * @param float[][] $base_rounds Base timings per round, same length. + * @return float + */ + private static function draw_ratio( array $head_rounds, array $base_rounds ): float { + $count = count( $head_rounds ); + $last = $count - 1; + $log_sum = 0.0; + for ( $r = 0; $r < $count; $r++ ) { + $pick = 0 === $last ? 0 : mt_rand( 0, $last ); + $log_sum += log( self::median( self::resample( $head_rounds[ $pick ] ) ) / self::median( self::resample( $base_rounds[ $pick ] ) ) ); + } + return exp( $log_sum / $count ); + } + + /** + * Two-sided 97.5% quantile of Student's t for a number of degrees of freedom. + * + * @param int $df Degrees of freedom, at least 1. + * @return float + */ + private static function t_975( int $df ): float { + static $table = array( + 1 => 12.706, + 2 => 4.303, + 3 => 3.182, + 4 => 2.776, + 5 => 2.571, + 6 => 2.447, + 7 => 2.365, + 8 => 2.306, + 9 => 2.262, + 10 => 2.228, + 11 => 2.201, + 12 => 2.179, + 13 => 2.160, + 14 => 2.145, + 15 => 2.131, + 16 => 2.120, + 17 => 2.110, + 18 => 2.101, + 19 => 2.093, + 20 => 2.086, + 21 => 2.080, + 22 => 2.074, + 23 => 2.069, + 24 => 2.064, + 25 => 2.060, + 26 => 2.056, + 27 => 2.052, + 28 => 2.048, + 29 => 2.045, + 30 => 2.042, + 40 => 2.021, + 60 => 2.000, + 120 => 1.980, + ); + if ( isset( $table[ $df ] ) ) { + return $table[ $df ]; + } + if ( $df > 120 ) { + return 1.960; + } + // Linear interpolation between the table rows above 30. + $lower = 30; + foreach ( array( 40, 60, 120 ) as $upper ) { + if ( $df < $upper ) { + return $table[ $lower ] + ( $table[ $upper ] - $table[ $lower ] ) * ( $df - $lower ) / ( $upper - $lower ); + } + $lower = $upper; + } + return 1.960; + } + + /** + * 95% t-interval on the geometric mean of independent ratios. + * + * @param float[] $ratios At least two ratios. + * @return array{point:float, low:float, high:float} + */ + private static function t_interval( array $ratios ): array { + $logs = array_map( 'log', array_values( $ratios ) ); + $count = count( $logs ); + $mean = array_sum( $logs ) / $count; + $sumsq = 0.0; + foreach ( $logs as $value ) { + $sumsq += ( $value - $mean ) * ( $value - $mean ); + } + $stderr = sqrt( $sumsq / ( $count - 1 ) ) / sqrt( $count ); + $half = self::t_975( $count - 1 ) * $stderr; + return array( + 'point' => exp( $mean ), + 'low' => exp( $mean - $half ), + 'high' => exp( $mean + $half ), + ); + } + + /** + * Ratio of medians (head / base) with a bootstrap percentile 95% CI. + * + * With one round this is the ratio of the two medians and a bootstrap over + * samples. With several rounds (a fresh worker pair each) the point estimate + * is the geometric mean of the per-round ratios and the bootstrap resamples + * rounds first, then samples within each round. + * + * @param array $head_samples Head timings: a flat list, or one list per round. + * @param array $base_samples Base timings in the same shape. + * @param int $seed Bootstrap seed. + * @return array{ratio:float, change_pct:float, ci_low:float, ci_high:float, verdict:string, rounds:int, resamples:int} + */ + public static function compare( array $head_samples, array $base_samples, int $seed ): array { + $head_rounds = self::rounds( $head_samples ); + $base_rounds = self::rounds( $base_samples ); + if ( count( $head_rounds ) !== count( $base_rounds ) ) { + throw new InvalidArgumentException( 'head and base must have the same number of rounds' ); + } + $count = count( $head_rounds ); + + if ( $count >= 2 ) { + $ratios = array(); + for ( $r = 0; $r < $count; $r++ ) { + $ratios[] = self::median( $head_rounds[ $r ] ) / self::median( $base_rounds[ $r ] ); + } + $interval = self::t_interval( $ratios ); + return array( + 'ratio' => $interval['point'], + 'change_pct' => ( $interval['point'] - 1.0 ) * 100.0, + 'ci_low' => $interval['low'], + 'ci_high' => $interval['high'], + 'verdict' => self::verdict( $interval['low'], $interval['high'] ), + 'method' => 't-interval over rounds', + 'rounds' => $count, + 'round_ratios' => $ratios, + ); + } + + $ratio = self::round_ratio( $head_rounds, $base_rounds ); + + mt_srand( $seed, MT_RAND_MT19937 ); + $ratios = array(); + for ( $b = 0; $b < self::RESAMPLES; $b++ ) { + $ratios[] = self::draw_ratio( $head_rounds, $base_rounds ); + } + sort( $ratios ); + $low = self::percentile( $ratios, 0.025 ); + $high = self::percentile( $ratios, 0.975 ); + + return array( + 'ratio' => $ratio, + 'change_pct' => ( $ratio - 1.0 ) * 100.0, + 'ci_low' => $low, + 'ci_high' => $high, + 'verdict' => self::verdict( $low, $high ), + 'method' => 'bootstrap over samples', + 'rounds' => 1, + 'resamples' => self::RESAMPLES, + ); + } + + /** + * Geometric mean of per-document ratios with a bootstrap 95% CI. + * + * Each resample draws documents with replacement, and within each drawn + * document draws rounds and then samples with replacement, as compare() does. + * + * @param array<array{head:array, base:array}> $documents Per-document head and base timings (flat or per round). + * @param int $seed Bootstrap seed. + * @return array{geomean:float, change_pct:float, ci_low:float, ci_high:float, verdict:string, documents:int, resamples:int}|null Null when no documents. + */ + public static function geomean_ratio( array $documents, int $seed ): ?array { + $documents = array_values( $documents ); + $count = count( $documents ); + if ( 0 === $count ) { + return null; + } + + $pairs = array(); + foreach ( $documents as $document ) { + $pairs[] = array( self::rounds( $document['head'] ), self::rounds( $document['base'] ) ); + } + $round_count = count( $pairs[0][0] ); + + if ( $round_count >= 2 ) { + // Per round, the geometric mean over documents; then a t-interval over rounds. + $round_geomeans = array(); + for ( $r = 0; $r < $round_count; $r++ ) { + $log_sum = 0.0; + foreach ( $pairs as $pair ) { + $log_sum += log( self::median( $pair[0][ $r ] ) / self::median( $pair[1][ $r ] ) ); + } + $round_geomeans[] = exp( $log_sum / $count ); + } + $interval = self::t_interval( $round_geomeans ); + return array( + 'geomean' => $interval['point'], + 'change_pct' => ( $interval['point'] - 1.0 ) * 100.0, + 'ci_low' => $interval['low'], + 'ci_high' => $interval['high'], + 'verdict' => self::verdict( $interval['low'], $interval['high'] ), + 'method' => 't-interval over rounds', + 'documents' => $count, + 'rounds' => $round_count, + 'round_geomeans' => $round_geomeans, + ); + } + + $log_sum = 0.0; + foreach ( $pairs as $pair ) { + $log_sum += log( self::round_ratio( $pair[0], $pair[1] ) ); + } + $geomean = exp( $log_sum / $count ); + + mt_srand( $seed, MT_RAND_MT19937 ); + $geomeans = array(); + $last = $count - 1; + for ( $b = 0; $b < self::RESAMPLES; $b++ ) { + $log_sum = 0.0; + for ( $d = 0; $d < $count; $d++ ) { + $pair = $pairs[ mt_rand( 0, $last ) ]; + $log_sum += log( self::draw_ratio( $pair[0], $pair[1] ) ); + } + $geomeans[] = exp( $log_sum / $count ); + } + sort( $geomeans ); + $low = self::percentile( $geomeans, 0.025 ); + $high = self::percentile( $geomeans, 0.975 ); + + return array( + 'geomean' => $geomean, + 'change_pct' => ( $geomean - 1.0 ) * 100.0, + 'ci_low' => $low, + 'ci_high' => $high, + 'verdict' => self::verdict( $low, $high ), + 'method' => 'bootstrap over documents and samples', + 'documents' => $count, + 'rounds' => 1, + 'resamples' => self::RESAMPLES, + ); + } + + /** + * Formats nanoseconds as milliseconds. + * + * @param float $ns Nanoseconds. + * @return string + */ + public static function format_ms( float $ns ): string { + $ms = $ns / 1e6; + if ( $ms < 0.1 ) { + return sprintf( '%.4f', $ms ); + } + if ( $ms < 10 ) { + return sprintf( '%.3f', $ms ); + } + return sprintf( '%.2f', $ms ); + } + + /** + * Formats a signed percentage. + * + * @param float $pct Percent. + * @return string + */ + public static function format_pct( float $pct ): string { + return sprintf( '%+.1f%%', $pct ); + } + + /** + * Formats a ratio CI as a percent-change range. + * + * @param float $low Lower ratio. + * @param float $high Upper ratio. + * @return string + */ + public static function format_ci( float $low, float $high ): string { + return sprintf( '[%s, %s]', self::format_pct( ( $low - 1.0 ) * 100.0 ), self::format_pct( ( $high - 1.0 ) * 100.0 ) ); + } + + /** + * Formats a coefficient of variation as a percentage. + * + * @param float $cv Ratio. + * @return string + */ + public static function format_cv( float $cv ): string { + return sprintf( '%.1f%%', $cv * 100.0 ); + } + + /** + * Formats an integer with thousands separators. + * + * @param int|float $number Number. + * @return string + */ + public static function format_int( $number ): string { + return number_format( (float) $number, 0, '.', ',' ); + } + + /** + * Formats a rate with one decimal and thousands separators. + * + * @param float $rate Rate. + * @return string + */ + public static function format_rate( float $rate ): string { + return number_format( $rate, 1, '.', ',' ); + } + + /** + * Formats bytes as megabytes. + * + * @param int $bytes Bytes. + * @return string + */ + public static function format_mb( int $bytes ): string { + return sprintf( '%.1f', $bytes / 1048576 ); + } +} diff --git a/tests/performance/html-api/lib/class-benchmark-synthetic.php b/tests/performance/html-api/lib/class-benchmark-synthetic.php new file mode 100644 index 0000000000000..4d90d3584a7bb --- /dev/null +++ b/tests/performance/html-api/lib/class-benchmark-synthetic.php @@ -0,0 +1,1308 @@ +<?php +/** + * Seeded synthetic document generator for the HTML API parsing benchmark. + * + * Every shape produces a full HTML document (doctype, html, head, body) whose + * body is built from generated chunks until the byte target is reached. The + * last chunk is trimmed at an element boundary, never mid-token, so the output + * is always well-formed for its shape and lands within 2% of the target size. + * + * Generation is deterministic: the same (shape, target bytes, seed) produces + * identical bytes on every run and on every PHP version, because all choices + * come from `mt_rand()` after `mt_srand( $seed, MT_RAND_MT19937 )`. + * + * @package WordPress + * @subpackage HTML-API + */ + +/** + * Generates synthetic HTML documents of a named shape at a byte target. + */ +class Benchmark_Synthetic { + /** + * Shape ids in report order, each with a one-sentence description. + * + * @var array<string, string> + */ + const SHAPES = array( + 'tags-dense' => 'Short elements with no attributes and almost no text, nested up to eight deep.', + 'text-long' => 'A few P elements holding long runs of plain ASCII prose with no entities, no "<" and no "&".', + 'text-entities' => 'Prose with frequent named and numeric character references, including invalid and unterminated ones.', + 'text-utf8' => 'Prose of multi-byte UTF-8 text in mixed scripts, with no character references.', + 'comments-many' => 'Thousands of short comments between tiny elements, some containing "--", plus bogus comments like "<!x>" and "<?pi?>".', + 'attributes-heavy' => 'Elements with 10 to 30 attributes each: quoted and unquoted values, duplicates, booleans, long class and style values, data-* names.', + 'nesting-deep' => 'Elements nested 200 deep and then unwound, repeated, to load the HTML Processor stack of open elements.', + 'script-style' => 'Large SCRIPT and STYLE bodies containing "<" and "</" that are not closers, plus TEXTAREA and TITLE RCDATA.', + 'whitespace-runs' => 'Long runs of spaces, tabs and newlines between tags and inside text.', + 'foreign-content' => 'Inline SVG and MathML subtrees with namespaced attributes, self-closing tags and foreignObject holding HTML.', + 'formatting-adoption' => 'Misnested formatting elements (B and I crossing, A inside A, P closed by DIV) that run the adoption agency on the HTML Processor, limited to the cases it supports: no block element between a formatting element and its closer, and no text before the inner closers.', + 'tables' => 'Tables with and without TBODY, implied rows and cells, nested tables, comments and hidden inputs between rows; no foster-parented content, because the HTML Processor bails on it.', + 'wordpress-post' => 'A block-themed WordPress page: block comments, paragraphs, headings, figures with srcset, lists, nested group DIVs with class and style, inline SVG icons and JSON scripts.', + ); + + /** + * Plain ASCII words used for prose. + * + * @var string[] + */ + const ASCII_WORDS = array( + 'the', + 'of', + 'and', + 'to', + 'in', + 'that', + 'is', + 'was', + 'for', + 'it', + 'with', + 'as', + 'his', + 'on', + 'be', + 'at', + 'by', + 'this', + 'had', + 'not', + 'are', + 'but', + 'from', + 'or', + 'have', + 'an', + 'they', + 'which', + 'one', + 'you', + 'were', + 'her', + 'all', + 'she', + 'there', + 'would', + 'their', + 'we', + 'him', + 'been', + 'has', + 'when', + 'who', + 'will', + 'more', + 'no', + 'if', + 'out', + 'so', + 'said', + 'what', + 'up', + 'its', + 'about', + 'into', + 'than', + 'them', + 'can', + 'only', + 'other', + 'new', + 'some', + 'could', + 'time', + 'these', + 'two', + 'may', + 'then', + 'do', + 'first', + 'any', + 'my', + 'now', + 'such', + 'like', + 'our', + 'over', + 'man', + 'me', + 'even', + 'most', + 'made', + 'after', + 'also', + 'did', + 'many', + 'before', + 'must', + 'through', + 'back', + 'years', + 'where', + 'much', + 'your', + 'way', + 'well', + 'down', + 'should', + 'because', + 'each', + 'just', + 'those', + 'people', + 'how', + 'too', + 'little', + 'state', + 'good', + 'very', + 'make', + 'world', + 'still', + 'own', + 'see', + 'men', + 'work', + 'long', + 'get', + 'here', + 'between', + 'both', + 'life', + 'being', + 'under', + 'never', + 'day', + 'same', + 'another', + 'know', + 'while', + 'last', + 'might', + 'great', + 'old', + 'year', + 'off', + 'come', + 'since', + 'against', + 'go', + 'came', + 'right', + 'used', + 'take', + 'three', + 'parser', + 'document', + 'element', + 'attribute', + 'token', + 'markup', + 'string', + 'buffer', + 'processor', + 'benchmark', + ); + + /** + * Multi-byte UTF-8 words in mixed scripts. + * + * @var string[] + */ + const UTF8_WORDS = array( + // Japanese. + '文書', + '解析', + '要素', + '属性', + 'トークン', + 'プロセッサ', + 'ベンチマーク', + '日本語', + 'これは', + 'テストです', + // Chinese. + '文档', + '解析器', + '元素', + '属性值', + '标记语言', + '性能测试', + // Korean. + '문서', + '파서', + '요소', + '속성', + '토큰', + '벤치마크', + // Arabic. + 'المستند', + 'المحلل', + 'عنصر', + 'سمة', + 'رمز', + 'اختبار', + // Hebrew. + 'מסמך', + 'מנתח', + 'אלמנט', + // Cyrillic. + 'документ', + 'парсер', + 'элемент', + 'атрибут', + 'токен', + 'тест', + 'производительность', + // Greek. + 'έγγραφο', + 'αναλυτής', + 'στοιχείο', + 'χαρακτηριστικό', + // Devanagari. + 'दस्तावेज़', + 'पार्सर', + 'तत्व', + 'विशेषता', + // Thai. + 'เอกสาร', + 'ตัวแยกวิเคราะห์', + 'องค์ประกอบ', + // Latin with diacritics. + 'déjà', + 'naïve', + 'Straße', + 'çocuk', + 'năm', + 'Ōtsuka', + 'ñandú', + 'łódź', + // Symbols and emoji (four-byte sequences). + '→', + '…', + '«»', + '🙂', + '🚀', + '🧪', + '✓', + ); + + /** + * Returns the shape ids in report order. + * + * @return string[] Shape ids. + */ + public static function shapes(): array { + return array_keys( self::SHAPES ); + } + + /** + * Returns a one-sentence description of a shape. + * + * @throws InvalidArgumentException When the shape is unknown. + * + * @param string $shape Shape id. + * @return string Description. + */ + public static function describe( string $shape ): string { + if ( ! isset( self::SHAPES[ $shape ] ) ) { + throw new InvalidArgumentException( "Unknown synthetic shape '{$shape}'." ); + } + + return self::SHAPES[ $shape ]; + } + + /** + * Generates a document of the given shape, within 2% of the byte target. + * + * @throws InvalidArgumentException When the shape is unknown or the target is too small. + * + * @param string $shape Shape id. + * @param int $target_bytes Target document size in bytes. + * @param int $seed Seed for `mt_srand()`. + * @return string The HTML document. + */ + public static function generate( string $shape, int $target_bytes, int $seed ): string { + if ( ! isset( self::SHAPES[ $shape ] ) ) { + throw new InvalidArgumentException( "Unknown synthetic shape '{$shape}'." ); + } + + mt_srand( $seed, MT_RAND_MT19937 ); + + $head = self::head( $shape ); + $tail = "\n</body>\n</html>\n"; + + $budget = $target_bytes - strlen( $head ) - strlen( $tail ); + if ( $budget < 64 ) { + throw new InvalidArgumentException( "Target of {$target_bytes} bytes is too small for shape '{$shape}'." ); + } + + $generator = 'chunk_' . str_replace( '-', '_', $shape ); + $body = ''; + $remaining = $budget; + + while ( $remaining > 0 ) { + $natural = mt_rand( 1500, 4000 ); + $chunk = self::$generator( min( $natural, $remaining ) ); + if ( '' === $chunk ) { + break; + } + $body .= $chunk; + $remaining -= strlen( $chunk ); + } + + return $head . $body . $tail; + } + + /** + * Builds the document prologue through the BODY start tag. + * + * @param string $shape Shape id. + * @return string Document head. + */ + private static function head( string $shape ): string { + if ( 'wordpress-post' === $shape ) { + return "<!DOCTYPE html>\n<html lang=\"en-US\">\n<head>\n" . + "<meta charset=\"UTF-8\">\n" . + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" . + "<title>Synthetic post &#8211; Benchmark</title>\n" . + "<meta name=\"generator\" content=\"WordPress 7.2\">\n" . + "<link rel=\"stylesheet\" id=\"wp-block-library-css\" href=\"/wp-includes/css/dist/block-library/style.min.css\" media=\"all\">\n" . + "<style id=\"global-styles-inline-css\">:root{--wp--preset--color--base:#fff;--wp--preset--color--contrast:#111}body{margin:0}</style>\n" . + "</head>\n<body class=\"post-template-default single single-post wp-embed-responsive\">\n"; + } + + return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n" . + "<meta charset=\"utf-8\">\n" . + "<title>Synthetic {$shape}</title>\n" . + "</head>\n<body>\n"; + } + + /* + * Shared helpers. + */ + + /** + * Picks one entry of a list. + * + * @param array $items Non-empty list. + * @return mixed One entry. + */ + private static function pick( array $items ) { + return $items[ mt_rand( 0, count( $items ) - 1 ) ]; + } + + /** + * Returns true with the given probability in percent. + * + * @param int $percent Probability, 0 to 100. + * @return bool Whether the event fired. + */ + private static function chance( int $percent ): bool { + return mt_rand( 1, 100 ) <= $percent; + } + + /** + * Builds prose from a word list, never exceeding `$max` bytes. + * + * Sentences are 6 to 16 words, capitalized, ending in a period, with a + * comma now and then and a newline after every few sentences. Entities + * are inserted after a word with the given percent chance. + * + * @param int $max Byte limit. + * @param string[] $words Word list. + * @param int $entity_rate Percent chance of an entity after each word. + * @param string[] $entities Entities to draw from when the chance fires. + * @return string Prose, possibly empty when nothing fits. + */ + private static function prose( int $max, array $words, int $entity_rate = 0, array $entities = array() ): string { + $out = ''; + $len = 0; + $in_sent = 0; + $sent_len = mt_rand( 6, 16 ); + $sentences = 0; + + while ( true ) { + $word = self::pick( $words ); + if ( 0 === $in_sent ) { + $word = ucfirst( $word ); + } + + $piece = $word; + if ( $entity_rate > 0 && self::chance( $entity_rate ) ) { + $piece .= self::pick( $entities ); + } + + ++$in_sent; + $sep = ' '; + if ( $in_sent >= $sent_len ) { + $piece .= '.'; + $in_sent = 0; + $sent_len = mt_rand( 6, 16 ); + ++$sentences; + if ( 0 === $sentences % 4 ) { + $sep = "\n"; + } + } elseif ( self::chance( 8 ) ) { + $piece .= ','; + } + + $need = ( $len > 0 ? 1 : 0 ) + strlen( $piece ); + if ( $len + $need > $max ) { + break; + } + + if ( $len > 0 ) { + $out .= $sep_prev; + } + $out .= $piece; + $len += $need; + $sep_prev = $sep; + } + + return $out; + } + + /** + * Appends `$piece` to `$out` if it fits in `$max`, reporting whether it did. + * + * @param string $out Output buffer, by reference. + * @param string $piece Piece to append. + * @param int $max Byte limit for `$out` after the append. + * @return bool Whether the piece was appended. + */ + private static function append( string &$out, string $piece, int $max ): bool { + if ( strlen( $out ) + strlen( $piece ) > $max ) { + return false; + } + $out .= $piece; + return true; + } + + /* + * Chunk generators. Each returns a self-contained, balanced fragment no + * longer than `$max` bytes, or an empty string if nothing fits. + */ + + /** + * `tags-dense`: trees of short attribute-less elements, little text. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_tags_dense( int $max ): string { + static $tags = array( 'div', 'span', 'b', 'i', 'em', 'strong', 'section', 'article', 'aside', 'small', 'u', 's', 'code', 'kbd', 'nav', 'label' ); + + $out = ''; + while ( true ) { + $depth = mt_rand( 1, 8 ); + $tree = self::dense_tree( $tags, $depth, $max - strlen( $out ) - 1 ); + if ( '' === $tree ) { + break; + } + $out .= $tree; + } + + if ( '' !== $out && strlen( $out ) < $max ) { + $out .= "\n"; + } + + return $out; + } + + /** + * Builds one nested tree for `tags-dense` within `$max` bytes. + * + * The budget is checked at every open tag so the tree is cut at an element + * boundary: a subtree that does not fit is dropped whole. + * + * @param string[] $tags Tag names to draw from. + * @param int $depth Remaining depth. + * @param int $max Byte limit. + * @return string Tree markup, or empty when even the root does not fit. + */ + private static function dense_tree( array $tags, int $depth, int $max ): string { + $tag = self::pick( $tags ); + $open = "<{$tag}>"; + $end = "</{$tag}>"; + if ( strlen( $open ) + strlen( $end ) > $max ) { + return ''; + } + + $inner = ''; + $limit = $max - strlen( $open ) - strlen( $end ); + if ( $depth > 1 ) { + $children = mt_rand( 1, 3 ); + for ( $i = 0; $i < $children; $i++ ) { + $child = self::dense_tree( $tags, $depth - 1, $limit - strlen( $inner ) ); + if ( '' === $child ) { + break; + } + $inner .= $child; + } + } elseif ( self::chance( 15 ) ) { + self::append( $inner, self::pick( self::ASCII_WORDS ), $limit ); + } + + return $open . $inner . $end; + } + + /** + * `text-long`: one P of long plain prose. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_text_long( int $max ): string { + $frame = strlen( "<p>\n</p>\n" ); + if ( $max < $frame + 8 ) { + return ''; + } + + $text = self::prose( $max - $frame, self::ASCII_WORDS ); + if ( '' === $text ) { + return ''; + } + + return "<p>\n{$text}</p>\n"; + } + + /** + * `text-entities`: prose with frequent character references. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_text_entities( int $max ): string { + static $entities = array( + '&amp;', + '&amp;', + '&nbsp;', + '&nbsp;', + '&#8217;', + '&#8217;s', + '&#8220;', + '&#8221;', + '&hellip;', + '&mdash;', + '&ndash;', + '&lt;', + '&gt;', + '&quot;', + '&#x27;', + '&#x2014;', + '&copy;', + '&eacute;', + '&notanentity;', + '&bogus;', + '&amp', + '&#', + '&#99999999;', + '&ampersand', + '&Aring', + ); + + $frame = strlen( "<p>\n</p>\n" ); + if ( $max < $frame + 8 ) { + return ''; + } + + $text = self::prose( $max - $frame, self::ASCII_WORDS, 35, $entities ); + if ( '' === $text ) { + return ''; + } + + return "<p>\n{$text}</p>\n"; + } + + /** + * `text-utf8`: prose of multi-byte words. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_text_utf8( int $max ): string { + $frame = strlen( "<p>\n</p>\n" ); + if ( $max < $frame + 8 ) { + return ''; + } + + $text = self::prose( $max - $frame, self::UTF8_WORDS ); + if ( '' === $text ) { + return ''; + } + + return "<p>\n{$text}</p>\n"; + } + + /** + * `comments-many`: short comments between tiny elements. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_comments_many( int $max ): string { + static $tags = array( 'span', 'b', 'i', 'em', 'li', 'td', 'p', 'div' ); + + $out = ''; + if ( ! self::append( $out, '<div>', $max - 7 ) ) { + return ''; + } + $limit = $max - 7; + + while ( true ) { + $roll = mt_rand( 1, 100 ); + if ( $roll <= 70 ) { + $words = array(); + $n = mt_rand( 2, 8 ); + for ( $i = 0; $i < $n; $i++ ) { + $words[] = self::pick( self::ASCII_WORDS ); + } + $text = implode( ' ', $words ); + if ( self::chance( 20 ) ) { + $text = str_replace( ' ', ' -- ', $text ); + } + $piece = "<!-- {$text} -->"; + } elseif ( $roll <= 78 ) { + $piece = '<!--' . self::pick( array( '', ' ', ' - ', ' -- ', ' ---- ', '--x--', ' spaced -- out -- dashes ' ) ) . '-->'; + } elseif ( $roll <= 84 ) { + $piece = '<!' . self::pick( array( 'x', 'ELEMENT br EMPTY', 'ENTITY nbsp', 'x bogus comment' ) ) . '>'; + } elseif ( $roll <= 90 ) { + $piece = '<?' . self::pick( array( 'pi?', 'xml version="1.0"?', 'php echo 1; ?', 'hint target data' ) ) . '>'; + } else { + $tag = self::pick( $tags ); + $piece = "<{$tag}></{$tag}>"; + if ( 'li' === $tag || 'td' === $tag ) { + $piece = '<span></span>'; + } + } + + if ( ! self::append( $out, $piece, $limit ) ) { + break; + } + if ( self::chance( 10 ) ) { + self::append( $out, "\n", $limit ); + } + } + + return $out . "</div>\n"; + } + + /** + * `attributes-heavy`: elements with 10 to 30 attributes each. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_attributes_heavy( int $max ): string { + static $elements = array( 'div', 'span', 'section', 'button', 'input', 'img', 'a', 'li', 'p', 'label' ); + static $booleans = array( 'hidden', 'disabled', 'checked', 'required', 'readonly', 'open', 'inert', 'autofocus', 'async', 'defer', 'itemscope' ); + static $plain = array( 'id', 'title', 'name', 'role', 'lang', 'dir', 'tabindex', 'href', 'src', 'alt', 'rel', 'target', 'type', 'value', 'placeholder', 'aria-label', 'aria-hidden', 'aria-describedby', 'itemprop', 'draggable', 'contenteditable', 'spellcheck', 'accesskey', 'for', 'width', 'height', 'loading', 'decoding' ); + static $css_props = array( 'color', 'background-color', 'margin', 'padding', 'font-size', 'line-height', 'display', 'position', 'top', 'left', 'width', 'max-width', 'border', 'border-radius', 'opacity', 'transform', 'transition', 'z-index', 'flex', 'grid-template-columns', 'text-align', 'letter-spacing', 'overflow', 'box-shadow' ); + static $css_values = array( '#1a2b3c', 'rgb(10, 20, 30)', '0', '1px', '0.75rem', '1.5', 'block', 'relative', 'auto', '100%', '42em', '1px solid #ccc', '4px', '0.9', 'translateY(-2px)', 'all 0.2s ease-in-out', '10', '1 1 auto', 'repeat(3, minmax(0, 1fr))', 'center', '0.02em', 'hidden', '0 2px 8px rgba(0,0,0,0.15)', 'var(--wp--preset--color--primary)' ); + + $out = ''; + $misses = 0; + while ( true ) { + $tag = self::pick( $elements ); + $attrs = array(); + $count = mt_rand( 10, 30 ); + for ( $i = 0; $i < $count; $i++ ) { + $kind = mt_rand( 1, 100 ); + if ( $kind <= 12 ) { + $attrs[] = self::pick( $booleans ); + } elseif ( $kind <= 20 ) { + // Unquoted value. + $attrs[] = self::pick( $plain ) . '=' . self::pick( array( '0', '42', 'abc', 'x-' . mt_rand( 1, 9999 ), 'true', 'ltr', '_blank', 'lazy' ) ); + } elseif ( $kind <= 30 ) { + // Single-quoted value. + $attrs[] = self::pick( $plain ) . "='" . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . "'"; + } elseif ( $kind <= 42 ) { + // Long class list. + $classes = array(); + $n = mt_rand( 8, 30 ); + for ( $j = 0; $j < $n; $j++ ) { + $classes[] = self::pick( array( 'wp-block', 'has', 'is', 'col', 'row', 'u', 'js' ) ) . '-' . self::pick( self::ASCII_WORDS ) . ( self::chance( 30 ) ? '-' . mt_rand( 1, 12 ) : '' ); + } + $attrs[] = 'class="' . implode( ' ', $classes ) . '"'; + } elseif ( $kind <= 54 ) { + // Long style value. + $decls = array(); + $n = mt_rand( 4, 14 ); + for ( $j = 0; $j < $n; $j++ ) { + $decls[] = self::pick( $css_props ) . ': ' . self::pick( $css_values ); + } + $attrs[] = 'style="' . implode( '; ', $decls ) . ';"'; + } elseif ( $kind <= 78 ) { + // data-* names, quoted. + $name = 'data-' . self::pick( self::ASCII_WORDS ) . ( self::chance( 50 ) ? '-' . self::pick( self::ASCII_WORDS ) : '' ); + $attrs[] = $name . '="' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . ' ' . mt_rand( 0, 99999 ) . '"'; + } elseif ( $kind <= 90 ) { + // Plain quoted value. + $name = self::pick( $plain ); + if ( 'href' === $name || 'src' === $name ) { + $attrs[] = $name . '="https://example.com/' . self::pick( self::ASCII_WORDS ) . '/' . self::pick( self::ASCII_WORDS ) . '-' . mt_rand( 1, 9999 ) . '/?x=' . mt_rand( 1, 999 ) . '&amp;y=' . self::pick( self::ASCII_WORDS ) . '"'; + } else { + $attrs[] = $name . '="' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . '"'; + } + } elseif ( ! empty( $attrs ) ) { + // Duplicate of an earlier attribute. + $attrs[] = self::pick( $attrs ); + } + } + + $is_void = 'input' === $tag || 'img' === $tag; + $piece = "<{$tag} " . implode( ' ', $attrs ) . '>'; + if ( ! $is_void ) { + $piece .= ( self::chance( 30 ) ? self::pick( self::ASCII_WORDS ) : '' ) . "</{$tag}>"; + } + $piece .= "\n"; + + if ( ! self::append( $out, $piece, $max ) ) { + // One element can run past a small limit; try a few more before giving up. + if ( ++$misses < 8 ) { + continue; + } + break; + } + } + + return $out; + } + + /** + * `nesting-deep`: nest to depth 200 and unwind. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_nesting_deep( int $max ): string { + static $tags = array( 'div', 'section', 'article', 'aside', 'nav', 'span', 'header', 'footer', 'figure', 'blockquote' ); + + $openers = array(); + $closers = array(); + $len = 0; + $depth = 200; + for ( $i = 0; $i < $depth; $i++ ) { + $tag = self::pick( $tags ); + $cost = strlen( $tag ) * 2 + 5; + if ( $len + $cost + 8 > $max ) { + break; + } + $openers[] = "<{$tag}>"; + $closers[] = "</{$tag}>"; + $len += $cost; + } + + if ( count( $openers ) < 2 ) { + return ''; + } + + $out = implode( '', $openers ) . self::pick( self::ASCII_WORDS ); + $out .= implode( '', array_reverse( $closers ) ); + if ( strlen( $out ) < $max ) { + $out .= "\n"; + } + + return $out; + } + + /** + * `script-style`: SCRIPT and STYLE bodies with false closers, plus RCDATA. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_script_style( int $max ): string { + static $js_lines = array( + 'if ( a < b && c > d ) { x = "</div>"; }', + 'var html = "<p>" + text + "</p>";', + 'for ( var i = 0; i < n; i++ ) { out += "<li>" + items[ i ] + "</li>"; }', + '// <!-- not a comment opener in a script -->', + 'const tpl = `<section class="${cls}">${body}</section>`;', + 'if ( x </ y ) { /* malformed but harmless */ }', + 'el.innerHTML = "<scr" + "ipt>alert(1)</scr" + "ipt>";', + 'return a <= b ? "<" : "</";', + 'while ( i-- > 0 && j < k ) { s += "</"; }', + 'document.write( "<\/script>" );', + 'const re = /<\/?[a-z][^>]*>/gi;', + 'let data = { "key": "value", "list": [ 1, 2, 3 ], "nested": { "a": "<b>" } };', + ); + static $css_lines = array( + 'a > b { color: red; }', + '/* comment with </ inside */', + '.x::before { content: "</div>"; }', + '.y[data-x="</ not a closer"] { display: none; }', + 'ul li:nth-child(2n+1) > a:hover { text-decoration: underline; }', + '@media (max-width: 782px) { .wp-block-group { padding: 0 1rem; } }', + '.z { background: url("data:image/svg+xml,<svg xmlns=\'http://www.w3.org/2000/svg\'/>"); }', + '.w::after { content: "<\\/styl"; }', + ':root { --gap: 1.5rem; --accent: #3858e9; }', + ); + static $rcdata = array( + '<b>not a tag</b> &amp; &lt;i&gt; still text <!-- nor a comment -->', + 'Plain text with < and </ and </text and &copy; inside', + 'Line one\nLine two <br> line three </br>', + ); + + $out = ''; + while ( true ) { + $roll = mt_rand( 1, 100 ); + $limit = $max - strlen( $out ); + + if ( $roll <= 45 ) { + $body = ''; + $inner_max = $limit - strlen( "<script>\n</script>\n" ); + while ( self::append( $body, self::pick( $js_lines ) . "\n", $inner_max ) ) { + if ( strlen( $body ) > mt_rand( 400, 1500 ) ) { + break; + } + } + if ( '' === $body ) { + break; + } + $piece = "<script>\n{$body}</script>\n"; + } elseif ( $roll <= 80 ) { + $body = ''; + $inner_max = $limit - strlen( "<style>\n</style>\n" ); + while ( self::append( $body, self::pick( $css_lines ) . "\n", $inner_max ) ) { + if ( strlen( $body ) > mt_rand( 300, 1200 ) ) { + break; + } + } + if ( '' === $body ) { + break; + } + $piece = "<style>\n{$body}</style>\n"; + } elseif ( $roll <= 92 ) { + $body = ''; + $inner_max = $limit - strlen( "<textarea>\n</textarea>\n" ); + while ( self::append( $body, self::pick( $rcdata ) . "\n", $inner_max ) ) { + if ( strlen( $body ) > mt_rand( 100, 500 ) ) { + break; + } + } + if ( '' === $body ) { + break; + } + $piece = "<textarea>\n{$body}</textarea>\n"; + } else { + $piece = '<title>' . self::pick( $rcdata ) . "</title>\n"; + } + + if ( ! self::append( $out, $piece, $max ) ) { + break; + } + } + + return $out; + } + + /** + * `whitespace-runs`: long whitespace runs between tags and inside text. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_whitespace_runs( int $max ): string { + static $tags = array( 'div', 'p', 'span', 'section', 'pre', 'li', 'em' ); + + $out = ''; + $misses = 0; + while ( true ) { + $tag = self::pick( $tags ); + $piece = self::whitespace_run() . "<{$tag}>" . self::whitespace_run(); + $words = mt_rand( 0, 2 ); + for ( $i = 0; $i < $words; $i++ ) { + $piece .= self::pick( self::ASCII_WORDS ) . self::whitespace_run(); + } + $piece .= "</{$tag}>"; + + if ( ! self::append( $out, $piece, $max ) ) { + // Runs are up to 400 bytes each; try a few more before giving up. + if ( ++$misses < 8 ) { + continue; + } + break; + } + } + + return $out; + } + + /** + * Builds one run of whitespace, 1 to 400 characters. + * + * @return string Whitespace. + */ + private static function whitespace_run(): string { + static $chars = array( ' ', ' ', ' ', "\t", "\n", "\n", "\r\n", "\f" ); + + $length = self::chance( 25 ) ? mt_rand( 1, 20 ) : mt_rand( 20, 400 ); + $out = ''; + $char = self::pick( $chars ); + for ( $i = 0; $i < $length; $i++ ) { + if ( self::chance( 10 ) ) { + $char = self::pick( $chars ); + } + $out .= $char; + } + + return $out; + } + + /** + * `foreign-content`: SVG and MathML subtrees. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_foreign_content( int $max ): string { + $out = ''; + while ( true ) { + $limit = $max - strlen( $out ); + $piece = self::chance( 65 ) ? self::svg_block( $limit ) : self::math_block( $limit ); + if ( '' === $piece ) { + break; + } + $out .= $piece; + } + + return $out; + } + + /** + * Builds one inline SVG within `$max` bytes. + * + * @param int $max Byte limit. + * @return string SVG markup, or empty when it does not fit. + */ + private static function svg_block( int $max ): string { + $open = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">'; + $end = "</svg>\n"; + if ( strlen( $open ) + strlen( $end ) > $max ) { + return ''; + } + + $inner = ''; + $limit = $max - strlen( $open ) - strlen( $end ); + $count = mt_rand( 3, 12 ); + for ( $i = 0; $i < $count; $i++ ) { + $roll = mt_rand( 1, 100 ); + if ( $roll <= 35 ) { + $d = 'M' . mt_rand( 0, 24 ) . ' ' . mt_rand( 0, 24 ); + $n = mt_rand( 2, 10 ); + for ( $j = 0; $j < $n; $j++ ) { + $d .= self::pick( array( 'L', 'C', 'Q', 'A', 'H', 'V', 'Z' ) ) . mt_rand( 0, 24 ) . '.' . mt_rand( 0, 9 ) . ' ' . mt_rand( 0, 24 ) . '.' . mt_rand( 0, 9 ); + } + $piece = "<path d=\"{$d}\" fill=\"currentColor\" fill-rule=\"evenodd\" clip-rule=\"evenodd\"/>"; + } elseif ( $roll <= 50 ) { + $piece = '<circle cx="' . mt_rand( 0, 24 ) . '" cy="' . mt_rand( 0, 24 ) . '" r="' . mt_rand( 1, 12 ) . '" stroke-width="1.5" stroke-linecap="round" />'; + } elseif ( $roll <= 60 ) { + $piece = '<use xlink:href="#icon-' . self::pick( self::ASCII_WORDS ) . '" xml:space="preserve" />'; + } elseif ( $roll <= 70 ) { + $piece = '<g transform="translate(' . mt_rand( 0, 12 ) . ',' . mt_rand( 0, 12 ) . ')"><rect x="1" y="1" width="10" height="10" rx="2"/><line x1="0" y1="0" x2="24" y2="24"/></g>'; + } elseif ( $roll <= 78 ) { + $piece = '<linearGradient id="g' . mt_rand( 1, 999 ) . '" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000" stop-opacity="0.5"/></linearGradient>'; + } elseif ( $roll <= 86 ) { + $piece = '<text x="2" y="12" font-size="8" text-anchor="middle"><tspan dy="1">' . self::pick( self::ASCII_WORDS ) . '</tspan></text>'; + } elseif ( $roll <= 94 ) { + $piece = '<foreignObject x="0" y="0" width="24" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="label"><p>' . self::pick( self::ASCII_WORDS ) . ' <b>' . self::pick( self::ASCII_WORDS ) . '</b></p><ul><li>' . self::pick( self::ASCII_WORDS ) . '</li></ul></div></foreignObject>'; + } else { + $piece = '<title>' . self::pick( self::ASCII_WORDS ) . ' icon</title><desc>' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . '</desc>'; + } + + if ( ! self::append( $inner, $piece, $limit ) ) { + break; + } + } + + return $open . $inner . $end; + } + + /** + * Builds one inline MathML block within `$max` bytes. + * + * @param int $max Byte limit. + * @return string MathML markup, or empty when it does not fit. + */ + private static function math_block( int $max ): string { + $open = '<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">'; + $end = "</math>\n"; + if ( strlen( $open ) + strlen( $end ) > $max ) { + return ''; + } + + $inner = ''; + $limit = $max - strlen( $open ) - strlen( $end ); + $count = mt_rand( 2, 8 ); + for ( $i = 0; $i < $count; $i++ ) { + $roll = mt_rand( 1, 100 ); + $a = chr( mt_rand( 97, 122 ) ); + $b = chr( mt_rand( 97, 122 ) ); + if ( $roll <= 30 ) { + $piece = "<mrow><mi>{$a}</mi><mo>=</mo><mn>" . mt_rand( 1, 99 ) . '</mn></mrow>'; + } elseif ( $roll <= 55 ) { + $piece = "<mfrac><mrow><mi>{$a}</mi><mo>+</mo><mi>{$b}</mi></mrow><mn>" . mt_rand( 2, 9 ) . '</mn></mfrac>'; + } elseif ( $roll <= 70 ) { + $piece = "<msup><mi>{$a}</mi><mn>" . mt_rand( 2, 5 ) . "</mn></msup><mo>&#x2212;</mo><msqrt><mi>{$b}</mi></msqrt>"; + } elseif ( $roll <= 82 ) { + $piece = "<mi mathvariant=\"bold\" definitionURL=\"#def-{$a}\">{$a}</mi><mspace width=\"0.5em\"/><mo stretchy=\"false\">(</mo><mi>{$b}</mi><mo>)</mo>"; + } elseif ( $roll <= 92 ) { + $piece = '<semantics><mi>' . $a . '</mi><annotation-xml encoding="text/html"><div class="fallback"><p>' . self::pick( self::ASCII_WORDS ) . ' <i>' . self::pick( self::ASCII_WORDS ) . '</i></p></div></annotation-xml></semantics>'; + } else { + $piece = '<mtext>' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . '</mtext>'; + } + + if ( ! self::append( $inner, $piece, $limit ) ) { + break; + } + } + + return $open . $inner . $end; + } + + /** + * `formatting-adoption`: misnested formatting elements. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_formatting_adoption( int $max ): string { + $out = ''; + while ( true ) { + $w = array(); + for ( $i = 0; $i < 8; $i++ ) { + $w[] = self::pick( self::ASCII_WORDS ); + } + $roll = mt_rand( 1, 100 ); + if ( $roll <= 18 ) { + // B and I crossing: the I closer follows the B closer directly. + $piece = "<p>{$w[0]} <b>{$w[1]} <i>{$w[2]} {$w[3]}</b></i> {$w[4]} {$w[5]}</p>\n"; + } elseif ( $roll <= 32 ) { + // A inside A: the second A start tag runs the adoption agency on the first. + $piece = "<p><a href=\"#{$w[0]}\">{$w[1]} <a href=\"#{$w[2]}\">{$w[3]}</a> {$w[4]} {$w[5]}</p>\n"; + } elseif ( $roll <= 46 ) { + // P closed by DIV, then a stray P closer that inserts an empty P. + $piece = "<p>{$w[0]} {$w[1]} <div>{$w[2]} {$w[3]}</div> {$w[4]}</p>\n"; + } elseif ( $roll <= 56 ) { + // A non-formatting element popped by the B closer, then its stray closer is ignored. + $piece = "<p><b>{$w[0]} <span>{$w[1]}</b> {$w[2]}</span> {$w[3]}</p>\n"; + } elseif ( $roll <= 66 ) { + $piece = "<p><em><strong>{$w[0]} <code>{$w[1]}</em></strong></code> {$w[2]} {$w[3]}</p>\n"; + } elseif ( $roll <= 74 ) { + // Three nested B elements; the P closer pops the outermost, whose own closer follows. + $piece = "<p><b>{$w[0]} <b>{$w[1]} <b>{$w[2]}</b></b> {$w[3]}</p></b> {$w[4]}\n"; + } elseif ( $roll <= 84 ) { + $piece = "<p><s>{$w[0]} <u>{$w[1]} <small>{$w[2]}</s></u></small> {$w[3]} {$w[4]}</p>\n"; + } elseif ( $roll <= 90 ) { + // A second NOBR runs the adoption agency on the first; FONT then pops the rest. + $piece = "<div><font color=\"red\" face=\"serif\">{$w[0]} <nobr>{$w[1]} <nobr>{$w[2]}</font></nobr> {$w[3]}</div>\n"; + } elseif ( $roll <= 95 ) { + // P closer pops the open B; the B closer then removes it from the formatting list. + $piece = "<p><b>{$w[0]} {$w[1]}</p></b> {$w[2]}\n"; + } else { + // P inside P closes the first. + $piece = "<p>{$w[0]} {$w[1]}<p><i>{$w[2]} <b>{$w[3]}</i></b> {$w[4]}</p>\n"; + } + + if ( ! self::append( $out, $piece, $max ) ) { + break; + } + } + + return $out; + } + + /** + * `tables`: tables with and without TBODY, implied rows and cells, nested tables. + * + * Stray non-whitespace text and stray tags directly inside a table would + * be foster-parented, which the HTML Processor does not support; this shape + * stays within what it parses so the document never bails. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_tables( int $max ): string { + $open = self::chance( 50 ) ? "<table>\n" : '<table class="wp-block-table is-style-stripes" border=1>' . "\n"; + $end = "</table>\n"; + if ( strlen( $open ) + strlen( $end ) > $max ) { + return ''; + } + + $inner = ''; + $limit = $max - strlen( $open ) - strlen( $end ); + + if ( self::chance( 30 ) ) { + self::append( $inner, '<caption>' . self::pick( self::ASCII_WORDS ) . ' <b>' . self::pick( self::ASCII_WORDS ) . "</b></caption>\n", $limit ); + } + if ( self::chance( 30 ) ) { + self::append( $inner, '<colgroup><col span="2"><col style="width: 40%"/></colgroup>' . "\n", $limit ); + } + if ( self::chance( 40 ) ) { + self::append( $inner, '<thead><tr><th scope="col">' . self::pick( self::ASCII_WORDS ) . '<th>' . self::pick( self::ASCII_WORDS ) . "</tr></thead>\n", $limit ); + } + + $style = mt_rand( 1, 4 ); + if ( 1 === $style ) { + self::append( $inner, "<tbody>\n", $limit ); + } + + $rows = mt_rand( 2, 20 ); + for ( $r = 0; $r < $rows; $r++ ) { + $cells = mt_rand( 1, 6 ); + if ( 4 === $style ) { + // Cells directly in the table imply both TBODY and TR. + $row = ''; + } elseif ( self::chance( 20 ) ) { + $row = '<tr class="row-' . $r . '">'; + } else { + $row = '<tr>'; + } + for ( $c = 0; $c < $cells; $c++ ) { + $tag = self::chance( 15 ) ? 'th' : 'td'; + $cell = "<{$tag}>" . self::pick( self::ASCII_WORDS ); + $sub = mt_rand( 1, 100 ); + if ( $sub <= 10 ) { + $cell .= ' <b>' . self::pick( self::ASCII_WORDS ) . '</b> <a href="#">' . self::pick( self::ASCII_WORDS ) . '</a>'; + } elseif ( $sub <= 16 ) { + $cell .= '<table><tr><td>' . self::pick( self::ASCII_WORDS ) . '<td>' . self::pick( self::ASCII_WORDS ) . '</table>'; + } elseif ( $sub <= 22 ) { + $cell .= '<p>' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . '</p>'; + } + // Closers are optional for TD and TH; omit some. + if ( 4 !== $style && self::chance( 50 ) ) { + $cell .= "</{$tag}>"; + } + $row .= $cell; + } + if ( 4 !== $style && self::chance( 60 ) ) { + $row .= '</tr>'; + } + $row .= "\n"; + + // Tokens the "in table" mode handles between rows: whitespace, comments, hidden inputs. + $between = mt_rand( 1, 100 ); + if ( $between <= 15 ) { + $row .= "<!-- row {$r} -->\n"; + } elseif ( $between <= 22 ) { + $row .= '<input type="hidden" name="row" value="' . $r . "\">\n"; + } elseif ( $between <= 30 ) { + $row .= "\t\t\n"; + } + + if ( ! self::append( $inner, $row, $limit ) ) { + break; + } + } + + if ( 1 === $style ) { + self::append( $inner, "</tbody>\n", $limit ); + } + if ( self::chance( 20 ) ) { + self::append( $inner, '<tfoot><tr><td colspan=3>' . self::pick( self::ASCII_WORDS ) . "</tfoot>\n", $limit ); + } + + if ( '' === $inner ) { + return ''; + } + + return $open . $inner . $end; + } + + /** + * `wordpress-post`: a block-themed page. + * + * @param int $max Byte limit. + * @return string Chunk. + */ + private static function chunk_wordpress_post( int $max ): string { + $out = ''; + $roll = mt_rand( 1, 100 ); + + if ( $roll <= 30 ) { + $frame = strlen( "<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->\n" ); + $text = self::prose( min( $max - $frame, mt_rand( 200, 900 ) ), self::ASCII_WORDS, 6, array( '&#8217;s', '&nbsp;', '&amp;', '&#8220;', '&#8221;', '&hellip;' ) ); + if ( '' === $text ) { + return ''; + } + if ( self::chance( 30 ) ) { + $text = preg_replace( '/^(\S+ \S+)/', '<strong>$1</strong>', $text, 1 ); + } + if ( self::chance( 30 ) ) { + $text = preg_replace( '/(\S+)\.$/', '<a href="https://example.com/' . self::pick( self::ASCII_WORDS ) . '/">$1</a>.', $text, 1 ); + } + $out = "<!-- wp:paragraph -->\n<p>{$text}</p>\n<!-- /wp:paragraph -->\n"; + } elseif ( $roll <= 40 ) { + $level = mt_rand( 2, 4 ); + $words = array(); + $n = mt_rand( 2, 8 ); + for ( $i = 0; $i < $n; $i++ ) { + $words[] = self::pick( self::ASCII_WORDS ); + } + $text = ucfirst( implode( ' ', $words ) ); + $id = strtolower( str_replace( ' ', '-', $text ) ); + $out = ( 2 === $level ? '<!-- wp:heading -->' : "<!-- wp:heading {\"level\":{$level}} -->" ) . "\n<h{$level} class=\"wp-block-heading\" id=\"{$id}\">{$text}</h{$level}>\n<!-- /wp:heading -->\n"; + } elseif ( $roll <= 50 ) { + $id = mt_rand( 100, 99999 ); + $slug = self::pick( self::ASCII_WORDS ) . '-' . self::pick( self::ASCII_WORDS ); + $w = self::pick( array( 1024, 1536, 2048 ) ); + $h = intdiv( $w * mt_rand( 50, 80 ), 100 ); + $base = 'https://example.com/wp-content/uploads/2026/' . str_pad( (string) mt_rand( 1, 12 ), 2, '0', STR_PAD_LEFT ) . "/{$slug}"; + $out = "<!-- wp:image {\"id\":{$id},\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n" . + "<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"{$w}\" height=\"{$h}\" src=\"{$base}-{$w}x{$h}.jpg\" alt=\"" . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . "\" class=\"wp-image-{$id}\" srcset=\"{$base}-{$w}x{$h}.jpg {$w}w, {$base}-300x" . intdiv( 300 * $h, $w ) . ".jpg 300w, {$base}-768x" . intdiv( 768 * $h, $w ) . ".jpg 768w, {$base}.jpg " . ( $w * 2 ) . "w\" sizes=\"(max-width: {$w}px) 100vw, {$w}px\" /><figcaption class=\"wp-element-caption\">" . ucfirst( self::pick( self::ASCII_WORDS ) ) . ' ' . self::pick( self::ASCII_WORDS ) . ".</figcaption></figure>\n" . + "<!-- /wp:image -->\n"; + } elseif ( $roll <= 60 ) { + $ordered = self::chance( 30 ); + $tag = $ordered ? 'ol' : 'ul'; + $out = ( $ordered ? "<!-- wp:list {\"ordered\":true} -->\n<ol class=\"wp-block-list\">" : "<!-- wp:list -->\n<ul class=\"wp-block-list\">" ) . "\n"; + $items = mt_rand( 2, 8 ); + for ( $i = 0; $i < $items; $i++ ) { + $out .= "<!-- wp:list-item -->\n<li>" . ucfirst( self::pick( self::ASCII_WORDS ) ) . ' ' . self::pick( self::ASCII_WORDS ) . ' ' . self::pick( self::ASCII_WORDS ) . "</li>\n<!-- /wp:list-item -->\n"; + } + $out .= "</{$tag}>\n<!-- /wp:list -->\n"; + } elseif ( $roll <= 78 ) { + // Nested group DIVs with class and style, holding paragraphs. + $depth = mt_rand( 1, 4 ); + $open = ''; + $close = ''; + for ( $i = 0; $i < $depth; $i++ ) { + $layout = self::pick( array( 'constrained', 'flex', 'grid', 'default' ) ); + $open .= "<!-- wp:group {\"layout\":{\"type\":\"{$layout}\"}," . ( self::chance( 50 ) ? '"style":{"spacing":{"padding":{"top":"var:preset|spacing|40"}}},' : '' ) . "\"backgroundColor\":\"base\"} -->\n" . + "<div class=\"wp-block-group has-base-background-color has-background is-layout-{$layout} wp-block-group-is-layout-{$layout}\" style=\"padding-top:var(--wp--preset--spacing--40);margin-block-start:0\">\n"; + $close = "</div>\n<!-- /wp:group -->\n" . $close; + } + $text = self::prose( mt_rand( 80, 300 ), self::ASCII_WORDS ); + $out = $open . "<!-- wp:paragraph {\"align\":\"center\"} -->\n<p class=\"has-text-align-center\">{$text}</p>\n<!-- /wp:paragraph -->\n" . $close; + } elseif ( $roll <= 88 ) { + $services = array( 'wordpress', 'mastodon', 'github', 'bluesky', 'rss' ); + $out = "<!-- wp:social-links {\"iconColor\":\"contrast\",\"className\":\"is-style-logos-only\"} -->\n<ul class=\"wp-block-social-links has-icon-color is-style-logos-only\">\n"; + $n = mt_rand( 2, 5 ); + for ( $i = 0; $i < $n; $i++ ) { + $s = self::pick( $services ); + $out .= "<!-- wp:social-link {\"url\":\"https://{$s}.example\",\"service\":\"{$s}\"} /-->\n" . + "<li class=\"wp-social-link wp-social-link-{$s} wp-block-social-link\"><a rel=\"noopener nofollow\" target=\"_blank\" href=\"https://{$s}.example\" class=\"wp-block-social-link-anchor\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm" . mt_rand( 0, 9 ) . '.5 14.5l-' . mt_rand( 1, 5 ) . ' 2 1-5 4-4z"></path></svg><span class="wp-block-social-link-label screen-reader-text">' . ucfirst( $s ) . "</span></a></li>\n"; + } + $out .= "</ul>\n<!-- /wp:social-links -->\n"; + } elseif ( $roll <= 94 ) { + $id = mt_rand( 1, 999 ); + $keys = array(); + $n = mt_rand( 3, 12 ); + for ( $i = 0; $i < $n; $i++ ) { + $keys[] = '"' . self::pick( self::ASCII_WORDS ) . mt_rand( 1, 99 ) . '":' . self::pick( array( 'true', 'false', 'null', (string) mt_rand( 0, 9999 ), '"' . self::pick( self::ASCII_WORDS ) . '"', '{"nested":"<\/div>"}', '["a","b"]' ) ); + } + $out = "<script type=\"application/json\" id=\"wp-script-module-data-@wordpress/interactivity-{$id}\">\n{\"config\":{" . implode( ',', $keys ) . "},\"state\":{\"core/image\":{\"lightbox\":{\"enabled\":true}}}}\n</script>\n"; + } else { + $out = "<!-- wp:separator {\"className\":\"is-style-wide\"} -->\n<hr class=\"wp-block-separator has-alpha-channel-opacity is-style-wide\"/>\n<!-- /wp:separator -->\n" . + '<!-- wp:spacer {"height":"' . mt_rand( 10, 100 ) . "px\"} -->\n<div style=\"height:" . mt_rand( 10, 100 ) . "px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n<!-- /wp:spacer -->\n"; + } + + if ( strlen( $out ) > $max ) { + // Fall back to the smallest block that fits, else stop. + $small = "<!-- wp:paragraph -->\n<p>" . self::pick( self::ASCII_WORDS ) . "</p>\n<!-- /wp:paragraph -->\n"; + return strlen( $small ) <= $max ? $small : ''; + } + + return $out; + } +} diff --git a/tests/performance/html-api/with-lock.php b/tests/performance/html-api/with-lock.php new file mode 100644 index 0000000000000..11ed19ef73754 --- /dev/null +++ b/tests/performance/html-api/with-lock.php @@ -0,0 +1,30 @@ +<?php +/** + * Runs a command while holding the benchmark lock. + * + * Usage: php tests/performance/html-api/with-lock.php <command> [args...] + * + * Use it for any timing or profiling outside bench.php, so that it never runs + * at the same time as a benchmark run. Exits with the command's exit code. + * + * @package WordPress + * @subpackage HTML-API + */ + +require_once __DIR__ . '/lib/class-benchmark-lock.php'; + +$command = array_slice( $argv, 1 ); +if ( empty( $command ) ) { + fwrite( STDERR, "usage: php with-lock.php <command> [args...]\n" ); + exit( 2 ); +} + +$lock = Benchmark_Lock::acquire( 'with-lock.php ' . implode( ' ', $command ) ); +// An empty spec lets the child inherit stdin, stdout and stderr; passing STDOUT or STDERR makes +// PHP seek a redirected file's shared offset, and the outputs then overwrite each other. +$process = proc_open( $command, array(), $pipes ); +if ( false === $process ) { + fwrite( STDERR, 'with-lock.php: could not start ' . $command[0] . "\n" ); + exit( 2 ); +} +exit( proc_close( $process ) ); diff --git a/tests/performance/html-api/worker.php b/tests/performance/html-api/worker.php new file mode 100644 index 0000000000000..424c8ef5a5082 --- /dev/null +++ b/tests/performance/html-api/worker.php @@ -0,0 +1,333 @@ +<?php +/** + * HTML API benchmark worker. + * + * Loads one checkout's HTML API, then answers one JSON request per stdin line + * with one JSON reply per stdout line. Nothing else is written to stdout. + * + * Usage: php [ini flags] worker.php --checkout <root> + * + * @package WordPress + * @subpackage HTML-API + */ + +ini_set( 'display_errors', 'stderr' ); +error_reporting( E_ALL ); + +require_once __DIR__ . '/lib/class-benchmark-bootstrap.php'; + +/** + * Writes one JSON line to stdout. + * + * @param array $reply Reply payload. + */ +function benchmark_worker_reply( array $reply ): void { + fwrite( STDOUT, json_encode( $reply, JSON_UNESCAPED_SLASHES ) . "\n" ); + fflush( STDOUT ); +} + +/** + * Writes a diagnostic line to stderr. + * + * @param string $message Message. + */ +function benchmark_worker_log( string $message ): void { + fwrite( STDERR, "worker: {$message}\n" ); +} + +/** + * Parses one document with the Tag Processor. + * + * @param string $html Document. + * @return array{tokens:int, bailed:null} + */ +function benchmark_worker_parse_tag( string $html ): array { + $tokens = 0; + $processor = new WP_HTML_Tag_Processor( $html ); + while ( $processor->next_token() ) { + ++$tokens; + } + return array( + 'tokens' => $tokens, + 'bailed' => null, + ); +} + +/** + * Parses one document with the HTML Processor. + * + * @param string $html Document. + * @return array{tokens:int, bailed:string|null} + */ +function benchmark_worker_parse_html( string $html ): array { + $tokens = 0; + if ( method_exists( 'WP_HTML_Processor', 'create_full_parser' ) ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + } else { + $processor = WP_HTML_Processor::create_fragment( $html ); + } + if ( null === $processor ) { + return array( + 'tokens' => 0, + 'bailed' => 'processor could not be created', + ); + } + while ( $processor->next_token() ) { + ++$tokens; + } + + $bailed = null; + $error = $processor->get_last_error(); + if ( null !== $error ) { + $bailed = $error; + if ( method_exists( $processor, 'get_unsupported_exception' ) ) { + $exception = $processor->get_unsupported_exception(); + if ( null !== $exception ) { + $bailed .= ': ' . $exception->getMessage(); + } + } + } + + return array( + 'tokens' => $tokens, + 'bailed' => $bailed, + ); +} + +/** + * Returns the parser function for a parser id. + * + * @throws InvalidArgumentException On an unknown parser id. + * + * @param string $parser 'tag' or 'html'. + * @return callable + */ +function benchmark_worker_parser( string $parser ): callable { + switch ( $parser ) { + case 'tag': + return 'benchmark_worker_parse_tag'; + case 'html': + return 'benchmark_worker_parse_html'; + } + throw new InvalidArgumentException( "Unknown parser: {$parser}" ); +} + +/** + * Reads the --checkout argument. + * + * @param string[] $argv Arguments. + * @return string|null Checkout root. + */ +function benchmark_worker_checkout_arg( array $argv ): ?string { + $count = count( $argv ); + for ( $i = 1; $i < $count; $i++ ) { + if ( '--checkout' === $argv[ $i ] && isset( $argv[ $i + 1 ] ) ) { + return $argv[ $i + 1 ]; + } + if ( 0 === strpos( $argv[ $i ], '--checkout=' ) ) { + return substr( $argv[ $i ], strlen( '--checkout=' ) ); + } + } + return null; +} + +/** + * Returns the short git head of a checkout, or null. + * + * @param string $checkout Checkout root. + * @return string|null + */ +function benchmark_worker_git_head( string $checkout ): ?string { + $output = array(); + $status = 1; + exec( 'git -C ' . escapeshellarg( $checkout ) . ' rev-parse --short HEAD 2>/dev/null', $output, $status ); + if ( 0 !== $status || empty( $output ) ) { + return null; + } + return trim( $output[0] ); +} + +/** + * Lists loaded extensions that hook execution to profile or trace it. + * + * They change timings, so bench.php refuses to run a worker that has any. + * + * @return string[] Extension names. + */ +function benchmark_worker_profilers(): array { + $names = array( 'xdebug', 'excimer', 'spx', 'tideways_xhprof', 'tideways', 'xhprof', 'blackfire', 'pcov', 'memprof', 'meminfo', 'opentelemetry', 'newrelic', 'ddtrace' ); + return array_values( array_filter( $names, 'extension_loaded' ) ); +} + +/** + * Reports the JIT mode actually in effect, or null when the JIT is off. + * + * @param bool $opcache_enabled Whether opcache is enabled for this process. + * @return string|null + */ +function benchmark_worker_jit_mode( bool $opcache_enabled ): ?string { + if ( ! $opcache_enabled ) { + return null; + } + $buffer = (string) ini_get( 'opcache.jit_buffer_size' ); + $jit = (string) ini_get( 'opcache.jit' ); + if ( '' === $buffer || '0' === $buffer || '' === $jit || '0' === $jit || 'off' === $jit || 'disable' === $jit ) { + return null; + } + return $jit; +} + +$checkout = benchmark_worker_checkout_arg( $argv ); +if ( null === $checkout ) { + benchmark_worker_log( 'usage: php worker.php --checkout <root>' ); + exit( 2 ); +} + +try { + Benchmark_Bootstrap::load( $checkout ); +} catch ( Throwable $e ) { + benchmark_worker_log( $e->getMessage() ); + exit( 2 ); +} + +$documents = array(); + +// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition +while ( false !== ( $line = fgets( STDIN ) ) ) { + $line = trim( $line ); + if ( '' === $line ) { + continue; + } + + $request = json_decode( $line, true ); + if ( ! is_array( $request ) || ! isset( $request['op'] ) ) { + benchmark_worker_reply( + array( + 'ok' => false, + 'error' => 'malformed request', + ) + ); + continue; + } + + try { + switch ( $request['op'] ) { + case 'hello': + $opcache_enabled = extension_loaded( 'Zend OPcache' ) && filter_var( ini_get( 'opcache.enable_cli' ), FILTER_VALIDATE_BOOLEAN ) && filter_var( ini_get( 'opcache.enable' ), FILTER_VALIDATE_BOOLEAN ); + benchmark_worker_reply( + array( + 'ok' => true, + 'php' => PHP_VERSION, + 'opcache' => $opcache_enabled, + 'jit' => benchmark_worker_jit_mode( $opcache_enabled ), + 'profilers' => benchmark_worker_profilers(), + 'checkout' => false === realpath( $checkout ) ? $checkout : realpath( $checkout ), + 'head' => benchmark_worker_git_head( $checkout ), + ) + ); + break; + + case 'load': + if ( ! isset( $request['id'] ) ) { + throw new InvalidArgumentException( 'load requires id' ); + } + if ( isset( $request['path'] ) ) { + $html = file_get_contents( $request['path'] ); + if ( false === $html ) { + throw new RuntimeException( "could not read {$request['path']}" ); + } + } elseif ( isset( $request['html'] ) ) { + $html = (string) $request['html']; + } else { + throw new InvalidArgumentException( 'load requires path or html' ); + } + $documents[ $request['id'] ] = $html; + benchmark_worker_reply( + array( + 'ok' => true, + 'id' => $request['id'], + 'bytes' => strlen( $html ), + ) + ); + break; + + case 'warmup': + // Untimed parses: at least `iterations`, and for at least `min_ms` milliseconds, + // so that the tracing JIT has compiled the hot paths before sampling. + if ( ! isset( $request['id'], $documents[ $request['id'] ] ) ) { + throw new InvalidArgumentException( 'unknown document id' ); + } + $html = $documents[ $request['id'] ]; + $parse = benchmark_worker_parser( (string) ( $request['parser'] ?? '' ) ); + $min = max( 0, (int) ( $request['iterations'] ?? 0 ) ); + $until_ns = hrtime( true ) + (int) ( max( 0, (float) ( $request['min_ms'] ?? 0 ) ) * 1e6 ); + $parses = 0; + while ( $parses < $min || hrtime( true ) < $until_ns ) { + $parse( $html ); + ++$parses; + } + benchmark_worker_reply( + array( + 'ok' => true, + 'parses' => $parses, + ) + ); + break; + + case 'calibrate': + case 'run': + if ( ! isset( $request['id'], $documents[ $request['id'] ] ) ) { + throw new InvalidArgumentException( 'unknown document id' ); + } + $html = $documents[ $request['id'] ]; + $parse = benchmark_worker_parser( (string) ( $request['parser'] ?? '' ) ); + $iterations = 'run' === $request['op'] ? max( 1, (int) ( $request['iterations'] ?? 1 ) ) : 1; + + gc_collect_cycles(); + if ( function_exists( 'memory_reset_peak_usage' ) ) { + memory_reset_peak_usage(); + } + $memory_before = memory_get_peak_usage( true ); + $alloc_before = memory_get_peak_usage( false ); + + $result = null; + $start = hrtime( true ); + for ( $i = 0; $i < $iterations; $i++ ) { + $result = $parse( $html ); + } + $ns = hrtime( true ) - $start; + + $memory_after = memory_get_peak_usage( true ); + $alloc_after = memory_get_peak_usage( false ); + + $reply = array( + 'ok' => true, + 'ns' => $ns, + 'tokens' => $result['tokens'], + 'bailed' => $result['bailed'], + ); + if ( 'run' === $request['op'] ) { + $reply['iterations'] = $iterations; + $reply['peak_bytes'] = max( 0, $memory_after - $memory_before ); + $reply['peak_alloc_bytes'] = max( 0, $alloc_after - $alloc_before ); + } + benchmark_worker_reply( $reply ); + break; + + case 'quit': + exit( 0 ); + + default: + throw new InvalidArgumentException( "unknown op: {$request['op']}" ); + } + } catch ( Throwable $e ) { + benchmark_worker_reply( + array( + 'ok' => false, + 'error' => get_class( $e ) . ': ' . $e->getMessage(), + ) + ); + } +} + +exit( 0 );