Skip to content

clazel browse draws real pages on macOS: pipeline, window, linked sheets, hostile pages (5.20, 5.23, 5.25 and 5.26 in part) - #161

Draft
atiernan wants to merge 102 commits into
mainfrom
claude/https-demo
Draft

atiernan wants to merge 102 commits into
mainfrom
claude/https-demo

Conversation

@atiernan

Copy link
Copy Markdown
Member

Note added when this PR was opened (2026-11-13). This is the join of Phase 5's stacks: with it, clazel browse draws real pages on macOS, in a window and headless, through the sandboxed renderer and GPU process, with linked style sheets fetched through the kernel, over http: and https:. Opened as a draft against main: it contains the branches of the 5.15, 5.16, 5.18, 5.19 and GPU/window PRs (and #153, #154), so its diff shrinks to its own commits as those merge. Merge it after them. It was built overnight with no CI run; the test and proof results below are the building session's own local runs. The proof jobs need a full-verification approval.

What is this PR's own (everything not in the PRs below it), in the order built:

  1. The renderer pipeline, 5.20 (claude/renderer-pipeline).
  2. clazel browse with a window on macOS and browse --headless, 5.23 (claude/browse-window). Its one clazel-sys call (clear_close_on_exec) is audited: row 267abea0, hash 18677bf4…, approved, by a separate audit session.
  3. The two joined: one list-socket framing, the renderer binary wired (claude/browse-pipeline).
  4. The first parts of 5.25 and 5.26: the HN-shaped fixture, geometry dump and comparison, the reftest runner (claude/reftest-harness).
  5. Linked style sheets end to end (claude/sheets-wired).
  6. Hostile pages, address bar and GPU-kill tests, 5.26 in part (claude/hostile-pages).
  7. The merge of 5 and 6, a process-wide start lock, and the two vendored WPT reference files (claude/phase5-top).
  8. An https page-and-sheet test; then, added by hand on 2026-11-13: a release clazel builds its missing helper binaries in release (8739173f), and a back button in the address bar strip (c6887bad).

For the maintainer's eye:

  • A sandbox widening decided without the maintainer: the GPU process's Seatbelt profile gained one rule, for Metal's compiler service, measured on one Mac (1a1b1d37).
  • A descriptor could leak between sandboxed processes: while one start had cleared close-on-exec on a socket, a concurrently started process could inherit it. Fixed by one process-wide lock around starts (35ea4ea2). Worth a review as a design, not only as a fix.
  • One lost frame in 15 runs remains in a_real_page_repaints_for_viewports_and_scrolls_and_follows_a_click, cause unknown; CI may fail on it intermittently.
  • Hostile-page findings: 10,000 rules over 100,000 elements paints nothing in 600 s (the rule index PR addresses it), and a million nested elements paints nothing in 600 s (the tree builder is quadratic in depth, known from Phase 4).
  • A killed GPU process is not restarted (the kernel's crash policy is a separate PR, and the host does not act on it yet).
  • Layout on the real Hacker News: content under <center> is centred too deep, so the story list sits about 200 px too far right. Not fixed: it needs a reading of HTML's "align descendants" and an ERRATA.md entry.
  • No WPT reftest has run: the 13 media-query reftests are XHTML (no XML parser), 4 need script.
  • Not verified: on-screen pixels, real clicks and typing in a live window (the back button included), Retina scale end to end. macOS on Apple silicon only.

The parts below are the building sessions' own descriptions, newest last. Where an earlier part says something "waits", check the later parts: most of those were done further up this same branch. The descriptions of parts 1, 2 and 4 are in the comments on this PR, for length.


Part 3: clazel browse draws pages: the renderer pipeline joined to the GPU process and the window (5.20 + 5.23)

Branch: claude/browse-pipeline, tip 22b19f7b479a59f8fa077ce1fe4499fdd36105c6.

Stacked. It contains, and must merge after:

  • claude/browse-window (5.23's host side, 50631b18), including its clazel-sys audit row 267abea0. That branch contains claude/gpu-sandbox (5.21a), claude/mac-window-08480b (5.21), claude/gpu-fallback-cfed1c (5.17c), clazel-gpu: batching, shaders and the Metal backend #153 claude/gpu-drawing-82b82a (5.17b) and claude/ipc-surfaces-cfd15e (5.18, with the docs/phase5-518-decision commit).
  • claude/renderer-pipeline (5.20, eb8fa5b6), which contains the same 5.18 and GPU branches, and 5.16's paint and the layout stack below it.

CI has not run. clazel-sys did not change on this branch. Its one change (clear_close_on_exec, 5.23) is audited at 18677bf4…, and cargo xtask check passes in full: STATIC CHECKS PASSED.

What

clazel browse URL (macOS, Apple silicon) shows real pages: the renderer of 5.20 lays a page out and writes display lists; the sandboxed GPU process draws them; the kernel's Paint puts them in the window, or browse --headless OUT URL writes them to a PNG or PPM.

  1. Merge of origin/claude/renderer-pipeline. The only conflict was Cargo.lock's clazel-renderer dependency list, resolved as the union of both sides. The auto-merged manifest then named clazel-gpu twice (a dependency from 5.23, a dev-dependency from 5.20); I dropped the dev-dependency. Both are in the merge commit.
  2. One list-socket framing. 5.20's renderer wrote a 4-byte big-endian length, while the GPU process's reader read little-endian. page::frame::frame_bytes now writes through clazel_gpu::channel::write_list, the one definition.
    • The renderer's tests split lists with channel::read_list.
    • The new test the_gpu_process_reads_what_the_renderer_writes runs the renderer's bytes through the GPU process's own loop (process::Server: framing, 5.17a's decoder and validator, batching) to the same pixels.
  3. The renderer binary runs the graphical pipeline when paired. With --list-fd N it opens /dev/fd/N before its sandbox, reads its --font PATH files then too, and runs run_graphics. Without it, the text path (Display) runs, as 5.18 decided.
    • clazel browse builds that graphics host only for window and headless modes. Renderers there get the system's TrueType faces as --font (Arial, Arial Bold, Times New Roman, Courier New on macOS), plus their GPU process.
    • --text, and every host without a window, is unchanged.
  4. The GPU sandbox gains one rule (below).
  5. Keys: Space, Page Up/Down and Up/Down are sent as scrolls (7/8 of the content area, or 40 CSS px), since 5.20's renderer ignores key Inputs. Left and Right do nothing.
  6. Tests, READMEs. Both crates' "what waits" sections, clazel-sandbox's measurement, and the top README.

The GPU sandbox: one Mach service allowed (a policy change, measured)

The first real pages crashed the sandboxed GPU process.

  • What crashed: a second page's glyph uploads made it abort (SIGABRT) inside Apple's driver. The crash report shows AGXMetalG13X, findOrCreateDriverProgramVariant<BlitComputeProgramVariant>, on Metal's compiler queue.
  • What the sandbox denied: the system log showed launchd … denied lookup: name = com.apple.MTLCompilerService … Sandbox restriction. The driver compiles blit variants when first needed. 5.21a's measurement drew only the fixed scenes, which never needed one.
  • What I tried first, and why it failed:
    • Before entering the sandbox, I drew every scene, then every upload length from 4 to 256 at every 4-byte offset below 64. That did not prevent the crash, so I reverted it.
    • Replaying the identical lists into the sandboxed process from a script never crashed. The trigger is environmental, not one list.
  • The change: seatbelt::GPU is now (deny default) plus (allow mach-lookup (global-name "com.apple.MTLCompilerService")). The plan's 5.21a text names "the IOKit and Mach services Metal needs", and Chromium's GPU sandbox allows the same service.
  • Measured (Apple M1 Pro, macOS 26.7.2, 2026-11-13):
    • the page flow passes;
    • clazel-gpu's tests/sandbox_macos.rs still sees every escape attempt denied (file, TCP, UDP, Unix socket, WindowServer, fork, exec);
    • the only denial still logged is mach-lookup of com.apple.analyticsd (the driver's telemetry), without effect, and left denied.
  • Cost: a compromised GPU process can send requests to Metal's compiler service, an XPC service that Apple sandboxes.
  • Where it is recorded: clazel-sandbox/src/seatbelt.rs and its README, and tests/policies.rs's shape test, now gpu_seatbelt_profile_is_deny_default_and_the_metal_compiler. This is a sandbox widening decided without the maintainer; it is flagged here for review.

Evidence (this machine)

End to end, with the real renderer and the real sandboxed GPU process on Metal, against an in-process server (crates/clazel/tests/browse_gpu.rs).

a_real_page_repaints_for_viewports_and_scrolls_and_follows_a_click uses a page with inline styles: a 40 px green link block, a 300 px red block and white text. The renderer lays it out in DejaVu Serif, vendored, so the layout does not depend on the machine.

  • First paint at 200×100, under the kernel's URL: green at the top, red at y 60.
  • A Viewport change repaints at 240×120.
  • A scroll of 60 px repaints with red at the top; scrolling back repaints with green.
  • A click on the link: the renderer sends Navigate, the kernel fetches /two, and the next page paints yellow under http://pages.test/two. The server saw / then /two.

headless_writes_a_real_pages_png runs clazel browse --headless page.png --size 200x120 on a blue page with white text.

  • The PNG decodes, with every CRC, the zlib header, every stored block and the Adler-32 checked, to 200×120.
  • More than 3/4 of its pixels are the page's background, #2050c0.
  • There are more than 20 white text pixels in the top 40 rows, and none below.

5.23's hand-built-list tests also pass: the Paint, the PNG, the PPM.

The window, opt-in (CLAZEL_WINDOW_TAKE_FOCUS=1, because clazel browse activates its window). tests/browse_window.rs runs clazel browse http://127.0.0.1:…/ against a served page until it logs clazel: showing http://…; this took 1.7 s. It then ends clazel, whose renderer and GPU process end with their sockets. The default run skips this step and says so; its other, no-focus window step passes (2.5 s).

A manual headless run (clazel browse --headless out.ppm --size 320x200 on a blue page with white and yellow text) gave 63,103 background pixels and white and yellow ink.

Renderer:

  • 18 lib tests, including the new agreement test and 5.20's tests on the new framing;
  • tests/pipeline.rs 13;
  • escape.rs 11.

Checks:

  • cargo test -j 4 -p clazel-renderer -p clazel-sandbox -p clazel-gpu -p clazel -p clazel-window: all pass, apart from one intermittent failure (next bullet).
  • One intermittent failure: in one of four runs of the multi-crate test command, a_hand_built_list_reaches_a_paint_under_the_kernels_url timed out waiting for its Paint. No crash report was written. Three reruns, one of them the same command, passed. That test's stand-in renderer waits a fixed 1 s before writing its list, which may be the cause; not investigated further.
  • cargo fmt --all -- --check: clean.
  • cargo clippy --workspace --all-targets --locked -- -D warnings: clean.
  • cargo xtask check: STATIC CHECKS PASSED.

Verification status

  • CI has not run. Its proof jobs need a reviewer of the full-verification environment.
  • cargo xtask verify --changed was not run, by instruction. No Kani, Miri or Verus run was made, and no lock was taken.
  • clazel-sys is unchanged here, and its audit row is merged.

Not done, unverified, or macOS only

  • The sandbox rule is measured on one Mac (M1 Pro, macOS 26.7.2). Another GPU or macOS version may need another service; then the GPU process dies and 5.17c's crash policy (not yet in the kernel) would act.
  • No screen readback. On-screen appearance in the window is unverified; only the GPU's pixels and the files are checked.
  • Real input to a live window was never sent: no clicks, wheel, typing or shortcuts. The click and scroll paths are tested through Input events to the host, and the address bar's logic by unit tests.
  • Fonts: the window and headless modes use the system's TrueType faces (Arial and others); the in-process tests use DejaVu. Nothing is bundled.
  • Linked style sheets wait for 5.19 (FetchStyle). Pages render with style elements, attributes and the user agent sheet.
  • Keys: the renderer ignores key Inputs, so the host sends scrolls for the scrolling keys, and Left/Right do nothing. Retina scale 2 through the whole pipeline was not run; the live screen reported scale 1.
  • macOS only: Linux needs clear_close_on_exec in its clazel-sys and a GPU backend; Windows has neither. Only aarch64-apple-darwin was compiled.
  • 5.17c's host side: the software indicator, the notice, and the kernel acting on GpuReady, GpuRefused or a GPU process's exit.
  • The Mach-port surface route (no copy per frame) is not built.
  • Miri was not run on the new tests.
  • docs/phases/phase-5.md was not edited, including its 5.21a text's measured profile.

Part 5: Linked style sheets end to end: renderer FetchStyle, host sub-fetch connections, process tests, reftests through browse --headless (5.19, 5.20, 5.25, 5.26)

Branch claude/sheets-wired, tip db361ae5. CI has not run on this branch or on any branch it contains.

Contains, and should merge after:

  • claude/reftest-harness (tip 5158380), which is stacked on:
  • Merged in here:
    • origin/claude/browse-pipeline (22b19f7b, 5.20 joined with 5.23), in dcbbe273;
    • origin/claude/style-fetches (261a1215, item 5.19), in 65d144bd. Its one conflict was in crates/clazel/src/lib.rs: browse-pipeline's real SpawnGpu/ToGpu arms were kept, next to style-fetches' placeholder arm for the Sub* outputs. The merge commit says so.

What

  1. Renderer: linked sheets through FetchStyle, render-blocking (faa76a0f).
    • Page::load starts SheetWait with the document's URL and encoding. run_graphics sends a FetchStyle { request, url } for each pending <link rel=stylesheet> whose media matches.
    • It collects StyleResponse/StyleBody/StyleEnd, or takes StyleFailed.
    • It decodes with 5.2's §3.2, using the response's content type, the document's encoding as the fallback.
    • It parses, cascades, and paints once every sheet is answered.
    • A failed sheet, one over MAX_BODY, or one the kernel timed out is left out.
    • HTML's same-origin quirks-mode content-type exception is implemented. Same-origin is computed in the renderer from the resolved href.
    • The memory table in the renderer's README gains linked sheets: at most MAX_BODY each, 8 in flight, 64 per document, so at most 512 MiB of bodies in flight.
  2. Host: carries out SubConnect, SubSend, SubClose and feeds SubNet (20d303a6, 067223ac).
    • Sub-fetch connections run on the same clazel_net::Driver as navigations. clazel_net::SubConns (crates/clazel-net/src/named.rs, 84 lines) maps the kernel's (renderer, serial, hop) names to Driver numbers from 2^63 up. It is a map: which connection, which bytes and which credentials are the kernel's.
    • Host shell: 299 lines (crates/clazel/src/lib.rs; the limit is 300).
    • Fix found by the timeout test: Host::event stepped a leading Tick and dropped its outputs, so the kernel's sweep (SubClose plus StyleFailed for a timed-out sub-fetch) went nowhere. Those outputs are now carried out.
    • pump steps a Tick when nothing arrives, so the kernel's 60 s timeout fires on an idle page.
  3. Tests through real processes (5.26's third bullet, on macOS): host, proven kernel, real sandboxed renderer and real GPU process, against the in-process server. They are in crates/clazel/tests/browse_gpu/sheets.rs, a module of browse_gpu.rs so the GPU support is shared without unused items.
    • A page styled only by its sheets is painted with them: green from the same-origin sheet, blue from the cross-origin one, checked by pixel.
    • The same-origin sheet request carries the page's cookie (sid=s3cret). The cross-origin one carries none and goes to its own Host. The server records both.
    • A sheet that never answers blocks painting. The test moves the host's clock past the kernel's 60 s, then the page is painted without the sheet (red background pixel).
    • The HN-shaped fixture is served with its sheet: /site.css?v=7 is requested, and the frame differs from the frame with a 404 sheet.
  4. The reftest runner with browse --headless as its renderer (9006b6e9, crates/clazel/tests/browse_gpu/reftests.rs).
    • Each page is served under its WPT path, with WPT's content type by extension. It is rendered by clazel browse --headless OUT --size 800x600 URL, and the RGB PNG is decoded to RGBA.
    • Plumbing check with pages written in the test: a green square matches a border-drawn square and mismatches a red one (pass). A match against the red square fails with exactly 10 000 pixels different.
  5. The fixture's screenshot (3ccf7b35): crates/clazel/tests/fixtures/hn/screenshot-macos-metal.png.
    • Stored because three browse --headless runs on this machine (macOS 26.7.2, M1 Pro, Metal, the system Arial/Times/Courier that browse loads) were byte-identical. SHA-256 89d079fc…0e955.
    • Its provenance is in the fixture's README. Nothing compares against it yet.
    • CI's comparison runs on lavapipe, a different driver, and needs its own stored image made by CI.
  6. READMEs (db361ae5):
    • the kernel README no longer says the host ignores the Sub* outputs;
    • clazel-net lists SubConns among its choices;
    • the renderer's "What waits" keeps the status gap and <base>.

Reftest counts (WPT reftests vendored here that are in the subset)

The only vendored WPT reftests are the 17 in clazel-style/tests/data/wpt-mediaqueries/ (WPT 4ad507b2). Nothing was tuned.

Outcome Count Which
Pass 0
Fail 0
Not run: reference not vendored 13 min-width-001.xht, mq-deprecated-001, mq-invalid-media-type-001 to -004 and -006, mq-invalid-media-type-layer-001, mq-negative-range-001, -002, mq-range-001, negation-001, -002. Each needs css/reference/ref-filled-green-100px-square.xht.
Not run: script 4 relative-units-001 to -004

None reached the renderer. Vendoring the one reference file (plus reference/ref-green-body.xht for the media-queries-00x.xht pages, which the runner did not detect as reftests) would let the 13 run. Those 13 are XHTML, served as application/xhtml+xml, which the renderer may not take. This was not tried.

Decisions

  • Sub-fetches share the navigation Driver rather than a second one: it keeps the host shell under 300 lines, and the number ranges cannot meet.
  • The renderer computes same-origin itself, from the resolved href, for HTML's quirks content-type exception. The kernel's credentials decision is separate and unchanged.
  • StyleResponse has no status, so any final response counts as success. Recorded under "What waits".
  • Idle ticking in pump: without it, a page waiting only on a hung sheet would never time out.
  • The test modules live under browse_gpu.rs, not as their own test targets, because the shared GPU support has items that only some files use, and lint overrides are not allowed.

Unverified, and known problems

  • A frame race that was already there makes the GPU process tests flaky.
    • Now and then a renderer commits and sends its Title, but no Paint follows within the cap (20 s in tests, 30 s in browse --headless).
    • On browse-pipeline's own tip (22b19f7b, 11 tests, none of this branch's): 1 failure in 5 runs, in headless_writes_a_real_pages_png.
    • On this branch (16 tests): about 1 in 2 to 3 runs. Most often reftests::browse_headless_renders_reftests, which starts browse --headless six times; also each of the sheets tests and the older a_real_page_…. Clean runs pass all 16.
    • Not diagnosed tonight. It is between the renderer's list and the GPU process's frame, and needs its own item before CI can rely on these tests.
  • No WPT reftest was rendered (see the counts).
  • The screenshot is checked only on this machine; nothing compares against it.
  • StyleResponse carries no status (as above). <base href> is not used for sheets.
  • The geometry comparison against a reference browser: scripts/reference-rects.mjs has still never been run.

Evidence (run here, -j 4)

  • cargo xtask check: RESULT: STATIC CHECKS PASSED.
  • cargo fmt --all -- --check, and cargo clippy --workspace --all-targets --locked -- -D warnings: clean.
  • cargo test -p clazel-net -p clazel-renderer: pass. The renderer has 20 lib tests, including the new linked_sheets_block_rendering_until_answered and the_stylesheet_content_type_quirk.
  • cargo test -p clazel: every target passes except browse_gpu, which passes in full on some runs and fails one or two tests on others (the race above).
  • No Kani, Miri or Verus: none was needed, and the lock was not taken.

Verification status

  • cargo xtask verify was not run locally in any form, as instructed.
  • CI has not run. Its proof jobs need a full-verification approval, and the branches below must merge first.
  • No tier was raised.
  • Untouched: the Status table, the roadmap line, docs/STATUS.md and its SVGs, SPEC-REVIEWS.md, SYS-AUDITS.md, CLAUDE.md and clazel-sys. No clazel-sys change was needed.

Part 6: Hostile pages, address bar and GPU process tests through real processes (5.26, part)

Branch: claude/hostile-pages, tip d848741ea1b95efd4d8fc3041c1d12e3ce22d08c.

Stacked on claude/browse-pipeline (22b19f7b), and must merge after it, with everything it contains (5.17b, 5.17c, 5.18, 5.20, 5.21, 5.21a, 5.23 and the clazel-sys audit row).

CI has not run. clazel-sys did not change. cargo xtask check: STATIC CHECKS PASSED.

This covers the parts of item 5.26 that need neither linked style sheets nor TLS. It does not touch crates/clazel/src, clazel-renderer/src/page/sheets.rs or the fixture; every test is in a new file, so it merges cleanly with claude/sheets-wired.

What

  1. The intermittent timeout, fixed at its two causes. Measured: a_hand_built_list_reaches_a_paint_under_the_kernels_url now passes 20 of 20.
    • In the GPU process (clazel-gpu, product code). It reads the kernel's channel and the list socket on two threads. When both were already waiting (a process slow to start), a list could be read before the kernel's GpuViewport. That list was drawn on a 1×1 target and its frame lost. A real renderer can hit this. Lists are now held while there is no viewport (at most 16 lists and 256 MiB; more ends the process), then drawn in order, each giving a frame.
    • In the test's stand-in renderer.
      • Its fixed 1 s sleep is replaced by a real condition. It is now a Perl script that reads the kernel's frames and writes its list once BodyEnd has arrived.
      • Its first version closed the list socket at the end of a loop iteration, through a lexical handle. The GPU process, seeing its list socket closed, correctly ended. Traced at 4 runs in 20; the handle now lives for the script.
  2. Hostile pages (crates/clazel/tests/hostile_graphics.rs, and tests/support/fonts.rs for the fonts). Each runs through a real sandboxed renderer and GPU process at 800×600, with inline <style> where the item says "sheet". Each records:
    • its outcome and time against a stated limit;
    • the renderer's peak RSS, sampled every 25 ms with ps (the pid recorded by /bin/sh, as in Phase 4), against item 4.13's terms plus 5.20's graphical terms;
    • the GPU process's peak against 1 GiB.
    • The README leaves parsed sheets unbounded, so the harness adds its own stated term of 64 bytes per byte of style text.
    • Every big case was run alone, in its own process group, under an 8 GB and 10-minute cap that kills the group.
  3. The address bar (tests/address_bar.rs), with a Perl stand-in GPU process paired with the real renderer, and the real pipeline for the title and margins cases.
  4. A killed GPU process (tests/gpu_killed.rs): what happens today is recorded, not required.
  5. browse --headless --wait S, a CLI option (default 30 s) the address-bar tests use.

Results (Apple M1 Pro, macOS 26.7.2, debug build, 2026-11-13)

Case Outcome Time (limit) Renderer peak / bound GPU peak In the default run
font-size: 1e9px painted 0.48 s (60) 14 / 97 MiB 22 MiB yes
width: 1e9px painted 0.48 s (60) 14 / 97 MiB 24 MiB yes
margins of -1e9px painted 0.49 s (60) 14 / 97 MiB 23 MiB yes
Font: offsets past the end painted 0.47 s (60) 14 / 97 MiB 22 MiB yes
Font: overlapping tables painted 0.45 s (60) 14 / 97 MiB 21 MiB yes
Font: loca not monotone painted 0.46 s (60) 14 / 97 MiB 23 MiB yes
Font: composite glyph containing itself painted 0.46 s (60) 14 / 97 MiB 22 MiB yes
Font: 32,767 contours painted 0.46 s (60) 14 / 97 MiB 22 MiB yes
Font: overlapping cmap format 4 segments painted 0.55 s (60) 14 / 97 MiB 22 MiB yes
Font: numGlyphs disagreeing with loca painted 0.47 s (60) 14 / 97 MiB 23 MiB yes
{ nested a million deep painted 1.18 s (120) 70 / 255 MiB 22 MiB yes
:is( nested 10,000 deep painted 0.50 s (120) 14 / 104 MiB 22 MiB yes
A 100,000-compound selector (100 nested divs) painted 104.7 s (120) 54 / 160 MiB 24 MiB ignored (slow)
A million rules painted 24.7 s (600) 2,149 / 3,720 MiB 24 MiB ignored (2 GiB)
64 MiB of style painted 83.0 s (600) 6,751 / 10,720 MiB 20 MiB ignored
10,000 rules over 100,000 elements nothing painted > 600 s (600) 291 / 1,960 MiB 18 MiB ignored, finding
A million elements, flat painted 137.1 s (600) 3,591 / 10,629 MiB 22 MiB ignored
A million elements, nested nothing painted; killed at the 600 s cap > 600 s (600) group 228 MiB — ignored, finding
A million-column table painted 453.6 s (600) 4,496 / 10,337 MiB 22 MiB ignored
colspan=1000 everywhere (1,000 rows × 100 cells) painted 48.8 s (600) 480 / 1,253 MiB 24 MiB ignored

Each ignored case's ignore reason holds these numbers. Run one alone with:

cargo test -p clazel --test hostile_graphics -- --ignored --exact NAME --nocapture

No case exceeded its memory bound, and no process crashed. The GPU process never passed 24 MiB.

What the font cases show, and don't: all seven painted. The test does not tell whether the renderer used the broken face or skipped it as "not a TrueType face" (5.20's rule); either is acceptable here.

Address bar

Case What happens
A page with margin-top: -1e9px Painted at most at the viewport (the content area). The window's page layer (layer_frame) has its top edge at the strip's bottom edge, and the strip is the host's own layer, drawn from the kernel's URL.
A GPU process claiming a surface larger than the viewport Dropped by the kernel, per 5.18's decision 3 (no Paint). Neither process is killed.
A GPU process sending more pixels than it claimed (twice the height) The kernel paints the claimed size; the host's slot holds 160×240. browse shows nothing (sizes differ). browse --headless exits 1: "no frame … within 5s".
A surface header that is not a buffer (slot 9) The host's reader refuses it and nothing is shown. Headless exits 1, not by a signal.
A buffer too small (1×1 for 160×120) Held, never shown. Headless exits 1.
A truncated buffer The reader refuses it and nothing is shown. Headless exits 1.
A title https://bank.example/login Only Paint.title. Paint.url is the kernel's http://hostile.test/; the title bar puts it after the URL (title_bar, unit-tested), and the strip draws only the URL.

A GPU process killed (today, without the crash policy)

The tab paints, then its GPU process is killed. On the next scroll the renderer's list write fails with a broken pipe, and the renderer exits with status 70. The kernel drops the tab's renderer, and nothing restarts either process. The host stays up and logs both exits. Killing "repeatedly" therefore stops after the first kill: there is no second GPU process to kill.

Escape attempts from inside the GPU process

These are already committed tests (crates/clazel-gpu/tests/sandbox_macos.rs, 5.21a): open a file, TCP, UDP, a Unix socket, the WindowServer, fork, exec. All were re-run passing after claude/browse-pipeline's profile change (the Metal compiler service). Nothing was added here.

Findings

  1. 10,000 rules over 100,000 elements paints nothing within 600 s. The renderer was at 291 MiB (bound 1,960), so this is time, not memory. Each rule is .cN span, over 100,000 divs each holding a span. The likely cause is that every rule is matched against every element, with no rule index, but this was not profiled. Not fixed (not a one-line bug).
  2. A million nested elements paints nothing within 600 s; it was killed at the cap with a group peak of 228 MiB. Phase 4 already measured the tree builder as quadratic in depth (hostile_pages.rs runs only 20,000 there). Not fixed.
  3. Slow, but within its limit: the 100,000-compound selector takes 105 s of its 120 s limit, for 100 elements. I chose the limit, and nothing else bounds this time (5.5's bound was not checked here). A page can hold its own tab for minutes.
  4. Memory, within the harness's own stated term: 64 MiB of style peaks at 6,751 MiB, about 105 bytes per byte of style text. The renderer README states no bound for parsed sheets, so the 64 bytes per byte term is this harness's own, and the case passes only because the other terms add headroom. A bound should be stated in the README.

What of 5.26 waits

  • The https demo (browse https://news.ycombinator.com/): Phase 3's TLS in the kernel.
  • The HN-shaped fixture through processes (the sheet through FetchStyle with and without the cookie, the stored screenshot, the reftest runner): claude/sheets-wired, in another session.
  • Linux and Windows: no GPU process or window there. The X server half of the "not a buffer" case is Linux's.
  • The crash policy case ("one tab goes to software, other sites' tabs do not", 5.17c): the policy is not in the kernel yet. Only today's behaviour is recorded.
  • Phases 2 to 4's rows kept (the demo, the escape attempts, WPT cookies/, pages.rs as box tree dumps, hostile_pages.rs with the new schema): not addressed here. hostile_pages.rs and the existing escape tests still pass on this branch.
  • "Rows with no header or more rows than announced (the renderer is killed)": not built here.
  • Strip read-back on screen: no screen capture (it needs the Screen Recording permission, and the display was asleep). The strip and page layers are checked by their layout, not by reading pixels.

Unverified

  • Only this Mac, debug build. Times and peaks would differ in a release build and on other machines.
  • Peaks are ps samples every 25 ms plus one after the outcome, so a short spike between samples can be missed.
  • The memory bound is my reading of the README's terms. The box count is assumed to be at most 3·N, and the sheet term is my own stated choice.
  • Miri was not run on the new tests; they start processes.
  • cargo xtask verify was not run, by instruction.

Part 7: Phase 5 top of stack: hostile-pages merged, one process start at a time, XHTML not run in reftests (5.23, 5.25, 5.26)

Branch claude/phase5-top, tip 4f6df6e3. CI has not run on this branch or on any branch it contains.

Contains, and should merge after:

  • claude/sheets-wired (db361ae5), which contains:
  • origin/claude/hostile-pages (d848741e, stacked on claude/browse-pipeline), merged in 9ecca749. No conflicts. It brings:
    • the GPU process holding lists that arrive before a viewport (ce60b42d);
    • the stand-in renderer waiting for its commit (9b2c0931);
    • browse --headless --wait;
    • 5.26's hostile-page, address-bar and GPU-kill tests.

What

  1. clazel-renderer: one process start at a time while descriptors are passed (35ea4ea2).

    • spawn_gpu and spawn_with_list clear a socket's close-on-exec flag, so every program started before the host closes its copy inherits that socket.
    • The docs put the duty on the caller ("start nothing else"). The host shell meets it with one thread. A process with several hosts on parallel threads, such as browse_gpu.rs's tests, does not.
    • launch now holds one process-wide lock (alone, public) for every start, from clearing the flag to closing the host's copy.
    • The tests' own starts (clazel browse --headless, and the fixture's cargo build) take the same lock through support/gpu.rs's output.
  2. Reftest runner: XHTML is not run, by reason (ee0a6945).

    • WPT serves .xht and .xhtml as application/xhtml+xml.
    • The renderer draws a notice for any type but HTML and plain text (page/mod.rs, "Other types"). An XHTML test against an XHTML reference would therefore compare two identical notices and "pass".
    • So a test, or a reference, that is XHTML is "not run: XHTML". This is decided by its path before anything is drawn.
  3. The media queries reftests' two WPT references, vendored (4f6df6e3).

    • The files, byte for byte from WPT 4ad507b28db0a636f528def9161d109fe11b93a3, with WPT's licence and each file's SHA-256 in crates/clazel/tests/wpt-references/README.md:
      • css/reference/ref-filled-green-100px-square.xht (568 bytes, b7d98bb3…a4f0);
      • css/mediaqueries/reference/ref-green-body.xht (434 bytes, f100f6bf…b0fa).
    • The maintainer approved fetching them in the other session, which fetched them; they were checked against the hashes here.
    • They are laid out by WPT path beside the runner, not in a crate's tests/data/, which holds only the inputs of xtask's converters.
    • The runner loads them, and the test asserts every reference its 17 tests name is present.

browse_gpu, 15 runs (one run at a time, cargo test -p clazel --test browse_gpu, its 16 tests in parallel)

Test Merged branch, before 35ea4ea2 With the lock in launch only With 35ea4ea2 (the tests' starts locked too)
sheets::linked_sheets_apply_with_the_cookie_only_same_origin 2 / 15 0 / 15 0 / 15
a_real_page_repaints_for_viewports_and_scrolls_and_follows_a_click 1 / 15 0 / 15 1 / 15
sheets::the_hn_shaped_fixture_is_served_with_its_sheet 1 / 15 0 / 15 0 / 15
headless_writes_a_ppm 1 / 15 0 / 15 0 / 15
headless_writes_a_real_pages_png 0 / 15 1 / 15 0 / 15
reftests::browse_headless_renders_reftests 1 / 15 1 / 15 0 / 15
The other 10 0 / 15 0 / 15 0 / 15
Runs with any failure 3 / 15 2 / 15 1 / 15
  • Control: the merged branch with --test-threads 1 had no failures in 15 runs.
  • Before the lock, the symptoms were:
    • renderers SIGKILLed right after commit;
    • a GPU process or renderer whose --list-fd was not its socket (cannot open the list socket N: Permission denied: the child opens its fonts first, so a read-only file sat at that number);
    • gpu 0: cannot start: No such file or directory;
    • frames lost after commit.
  • With the lock in launch only, both failures were in tests that start browse --headless from the test process outside the lock. One showed clazel-renderer: cannot open the list socket 3: Permission denied.
  • Not fixed: 1 failure in 15 remains. a_real_page_… got commit and Title, no process ended, and no Paint came within 20 s. That is the original "lost frame" symptom, without the descriptor symptoms. The host's log does not record the GPU process's messages (FrameReady, lists read), so it is not known whether the list was written, read, drawn, or its frame dropped by the kernel. Finding it needs that logging. No retries or longer waits were added.
  • The first measurement run overlapped a find / I had started by mistake. The two later sets ran with the machine otherwise idle.

Reftests: the WPT reftests vendored here that are in the subset (clazel-style's wpt-mediaqueries, WPT 4ad507b2), with their references vendored (4f6df6e3)

Outcome Count Which
Pass 0
Fail 0 none, so there are no pixel counts to list
Not run: XHTML 13 min-width-001.xht (the test itself). Through their reference css/reference/ref-filled-green-100px-square.xht: mq-deprecated-001, mq-invalid-media-type-001 to -004 and -006, mq-invalid-media-type-layer-001, mq-negative-range-001 and -002, mq-range-001, negation-001 and -002.
Not run: script 4 relative-units-001 to -004 (their reference, css/mediaqueries/reference/ref-green-body.xht, is XHTML too)
  • No reference is missing. The test asserts every reference the 17 name is present.
  • Why XHTML is not attempted.
    • WPT serves .xht as application/xhtml+xml.
    • HTML's navigation sends a response with an XML MIME type to its XML document loading, which parses it as XML. This renderer has no XML parser and draws a notice for the type.
    • So two XHTML pages would compare two identical notices, and an HTML test against an XHTML reference would compare a page with a notice. Neither result would mean anything.
    • Serving the files as text/html would be outside the standard, so it was not done. Nothing was tuned or special-cased.
  • media-queries-001 to -003.xht are not reftests: they have no rel=match or rel=mismatch.
  • What would let the 13 run: the renderer taking application/xhtml+xml, which needs an XML parser (one of our own, under rule 8).

The six crates' tests, once on the merged branch

cargo test -j 4 --locked -p clazel -p clazel-renderer -p clazel-gpu -p clazel-net -p clazel-kernel -p clazel-ipc: exit 0.

  • 45 test targets.
  • 320 passed, 0 failed, 10 ignored. The ignored ones are the #[ignore]d hostile cases: 8 in hostile_graphics.rs, 2 in hostile_pages.rs.
  • browse_gpu passed 16 of 16 in that run.

Checks

  • cargo xtask check: RESULT: STATIC CHECKS PASSED.
  • cargo fmt --all -- --check, and cargo clippy --workspace --all-targets --locked -- -D warnings: clean.
  • No Kani, Miri or Verus: none was needed, and the lock was not taken.

Unverified

  • The remaining lost frame (1 in 15, above). Its cause is not known.
  • The lock covers starts through clazel_renderer::launch, and test starts that use support/gpu.rs's output. Other test files that start programs with a plain Command beside in-process hosts are not changed: address_bar.rs, gpu_killed.rs, hostile_graphics.rs and host.rs. They passed in the full run above.
  • No WPT reftest was rendered: all 17 are not run, by the reasons above.
  • CI has not run. Its proof jobs need a full-verification approval, and the branches below must merge first.
  • cargo xtask verify was not run locally in any form, as instructed.
  • No tier was raised.
  • Untouched: the Status table, the roadmap line, docs/STATUS.md and its SVGs, SPEC-REVIEWS.md, SYS-AUDITS.md, CLAUDE.md and clazel-sys. No clazel-sys change was needed.

Part 8: The https demo on Hacker News works; one test pins its path

Branch: claude/https-demo, tip f67c8592ed5965b46d3ae680dd5742a58f097f66, cut from origin/claude/phase5-top (ee0a6945). It contains one commit on top: a test only, no product change.

CI has not run. clazel-sys did not change. cargo xtask check: STATIC CHECKS PASSED; clippy on clazel is clean; the new test passes.

The maintainer is right: TLS is in the kernel, and the https demo works today without any change. The plan's "waits for Phase 3's TLS in the kernel" is out of date.

What I ran (9 requests to the site in all, no loops)

Machine: Apple M1 Pro, macOS 26.7.2, debug build, from ee0a6945.

  1. clazel fetch https://news.ycombinator.com/: works.
    • 200 OK from nginx, Transfer-Encoding: chunked, 34,901 bytes, 1.4 s.
    • The TLS 1.3 handshake (ChaCha20-Poly1305, X25519) and certificate validation against Mozilla's roots passed. The chunked body parsed.
    • No content encoding was sent: we send no Accept-Encoding.
  2. clazel browse --headless …/hn.png --size 1280x900 https://news.ycombinator.com/: works, 2.46 s end to end.
    • The page arrives over connection 0 … (TLS).
    • The renderer sends FetchStyle for news.css?…, which goes on its own TLS sub-connection (SubConnect { tls: true }), and StyleResponse comes back.
    • Title is "Hacker News", and the PNG is written.
  3. An item page, headless: works, 3.0 s.
    • I used item?id=50478515 (IceWM, 36 comments) rather than the 3,000-comment thread.
    • Same path; the title is "IceWM | Hacker News".
  4. clazel browse https://news.ycombinator.com/ in a window: works.
    • The log says clazel: showing https://news.ycombinator.com/ (1024 by 732, slot 0).
    • Time from start to first paint in the window: 2.53 s, debug build, including starting the renderer and GPU processes and both TLS connections.
    • Not captured as an image: screencapture failed with "could not create image from display" because the display was asleep, so there is no window screenshot. Its content is the same pipeline as the headless image.

No layer failed:

  • the TLS handshake and certificate validation;
  • the host's Connect { tls: true } in headless and window mode;
  • the sub-fetch's TLS;
  • chunked transfer encoding;
  • the page's size (34 KiB) against the limits.

So nothing in host wiring needed fixing, and no change touches TLS, X.509 or certificate validation.

Committed

crates/clazel/tests/https_sheet.rs: an https page with an https sheet through the real renderer and GPU process, against the loopback rustls server the host tests use. It checks:

  • the sheet comes through FetchStyle on its own TLS connection (two TLS connections in the log);
  • the sheet's request carries the cookie the same-origin page set;
  • the painted frame has the sheet's colour.

It passes in 1.4 s. Nothing fetched from the real site is committed.

The real front page against a real browser, in words

Screenshot: /private/tmp/claude-503/hn/hn.png (1280×900).

Right:

  • the page is 85% wide and centred;
  • the orange header bar has a white-bordered logo box, "Hacker News" in bold, and new | past | comments | ask | show | jobs | submit, with "login" at the right;
  • the beige #f6f6ef background;
  • 30 numbered stories, each with its title in black and its site in small grey after it;
  • the subtext line in small grey (points, user, age, hide, comments);
  • Verdana falls back to Arial, at sizes close to a browser's;
  • row spacing close to a browser's;
  • the title is "Hacker News".

Missing, as expected: images (Phase 6). There are no vote arrows and no Y logo; the logo's white border shows as an empty box, which is right without the image.

Wrong: the story list starts about 200 px too far right.

  • In a browser the rank numbers sit near the left edge of the beige area.
  • Here they start near x = 313 instead of about 110. The rank and vote columns together are about 245 px wide.
  • The layer is layout, not wiring: clazel-layout/clazel-style's HTML "align descendants". HN wraps everything in <center> and has no doctype (quirks mode).
    • Read literally, HTML §15.2 (the pinned 4c54fae1 text) has <center> align all its block-level descendants. That includes the story table nested in a cell of #hnmain, so our layout centres it within its cell, as an auto-width table at its max-content width.
    • Browsers implement center by inheriting text-align (-webkit-center), and in quirks mode the HTML table rule table { text-align: initial } stops it at #hnmain, so the inner table is not centred.
  • Reproduced without HN's markup, with my own minimal page (/private/tmp/claude-503/hn/www/q-center.html): <center><table width=85%><tr><td><table>…. Ink starts at x = 530 under <center>, and at x = 6 without it, in quirks and standards mode alike.
    • Without news.css the offset is the same, so the sheet is not the cause.
    • It is not table column sizing: §3.9.1's used width is implemented as written.
  • Not fixed. The fix is a spec-interpretation decision (CLAUDE.md rule 9: an ERRATA.md entry, saying where align descendants stops) in clazel-layout/clazel-style, not host wiring or a one-line bug.

The item page

Screenshot: /private/tmp/claude-503/hn/item.png.

Right:

  • the header;
  • the story title, site and subtext;
  • comment headers (user, age, parent, next, [–]) in grey;
  • comment text in the right font with paragraphs;
  • reply links;
  • nesting is visible.

Wrong:

  • Short comments are pushed right while long ones look right. Each comment is its own auto-width table inside <center>, so the same align-descendants behaviour centres every comment narrower than the page. HN's indent cells (an image s.gif with a width attribute) do reserve their width: long comments show the right indentation steps.
  • A large blank area under the subtext, with "help" floating at the right. That is where the comment form is (textarea and "add comment" button). Form controls are not drawn, but their box space is kept. That matches the plan's scope (no forms in Phase 5) but looks broken.

Screenshots and scratch files (none committed)

  • /private/tmp/claude-503/hn/hn.png: the front page, headless, 1280×900.
  • /private/tmp/claude-503/hn/item.png: an item page, headless, 1280×900.
  • No window screenshot: the display was asleep.
  • /private/tmp/claude-503/hn/local-nocss.png: the saved front page served locally without news.css (the offset is still there).
  • /private/tmp/claude-503/hn/q-plain.png and /private/tmp/claude-503/hn/q-center.png: my minimal reproduction without and with <center>.
  • /private/tmp/claude-503/hn/fetch.out and /private/tmp/claude-503/hn/www/front.html: the fetched front page, kept in scratch only.

Unverified

  • No window screenshot (display asleep). Window interaction (clicks, scroll, back) on the real site was not exercised, to keep the request count small.
  • Times are from one run each on a debug build; a release build would be faster.
  • A browser screenshot was not taken for side-by-side comparison. The description above is against HN's known appearance.

🤖 Generated with Claude Code

atiernan and others added 30 commits November 9, 2026 18:42
…l 5.26

5.0 left open that 5.18 retires `Display` and `Show` while (g) keeps
`clazel-text`, which uses them, until 5.26. Decision: 5.18 adds `Paint`
and the new messages beside `Display` and `Show`, which stay unchanged
with their proven theorems; the restated theorems cover both; 5.26
deletes the old path with `clazel-text` in one pull request. Retiring
`Display` early would leave no working browser and no end-to-end
evidence until 5.26, and the "doubling" (g) feared does not arise since
the `Show` theorems already exist.

Taken by a Claude session on the maintainer's delegation, from the
plan's goals. The Status table and the roadmap line are left as they
are, to be refreshed separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st form

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/clazel-sys/src/macos/window.rs gets its own lists: ten foreign
functions (the Objective-C runtime, pthread_main_np,
CGSessionCopyCurrentDictionary, and the Core Foundation and Core Graphics
calls that make a CPU image) and 19 casts of objc_msgSend. No new form: the
file uses #153's dispatcher cast exactly. A planted test shows the lists are
per file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d contracts (5.17c)

The decisions are in each module's header: the blocklist format and its
matching (src/blocklist.rs, data/blocklist, shipped empty), the start that
checks the adapter before any list (src/start.rs), and the crash policy as a
pure state machine for the kernel to take over in 5.18 (src/policy.rs), with
its contracts: invariants kept, contained, sites, restarts bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h policy (5.17c)

phase-5.md item 5.17c. Gpu::start is now the only way to make a Gpu: it
reads the backend's adapter (a new Backend::adapter) and checks it against
the shipped blocklist before any list, answering GpuReady { software } or a
refusal. The blocklist parser is ours (rule 8), bounded and panic-free. The
crash policy steps one tab's record and the shared site record per event,
rules 1 to 4 of the item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…used, Viewport, Input, GpuViewport)

The schema gains the messages of phase-5.md item 5.18 beside Display (tag
10), which stays unchanged until 5.26, as decided under 5.0 on 2026-11-09.
No tag is reused. A GPU process's frames are decoded with
decode_gpu_message, which accepts only GPU-to-kernel messages; Title joins
the renderer-to-kernel set. Spec and contract only (CLAUDE.md rule 3); the
codec and proofs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CSS 2.1 §17 with CSS Tables 3 (Editor's Draft at csswg-drafts e13fe879) for
automatic layout and HTML §4.9.12.1 for the grid. The states (HTML's forming
variables, a running share, a column's measures, the table width input, a
row's extent, cell alignment), their invariants, and each step's contract,
before the implementation. The module's header lists every modelling choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5.17c)

Blocklist: the shipped table parses and is empty; each field; hostile text
(tabs, CR, non-ASCII, NUL, bad fields, empty ranges, limits) refused with its
line; matching at range ends, wildcards, unknown versions, software adapters;
arbitrary bytes never panic. Start: every answer. Policy: every transition,
the restart bound over an hour of crashes, and containment and the
invariants over a pseudo-random trace. The plan's test: a fake adapter the
table lists is refused, the policy starts software, and every scene drawn
there equals the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essages

The exec codec reads and writes Title, FrameReady, GpuReady, GpuRefused,
Viewport, Input and GpuViewport, and decode_gpu_message accepts only a GPU
process's messages; both are proven against the spec (Verus: 210
verified, 0 errors). lemma_gpu_decode_direction: no message is accepted
from both kinds of process. Twelve new mutations, each with a certificate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New macos/window.rs (Apple silicon): the shared application, an NSWindow,
its content view's layer and sublayers, a CPU image as layer contents
(CFDataCreate, CGImageCreate), an IOSurface as layer contents, and events
read with nextEventMatchingMask:untilDate:inMode:dequeue: and handed back
with sendEvent: only when the caller says so. No class, delegate or callback
is defined in Rust. MainThread (pthread_main_np) gates everything; App
refuses to start without a window server (CGSessionCopyCurrentDictionary),
where AppKit would end the process. Accessors are sent only to event types
that have them; rectangles are checked finite; a window is not released
when closed. metal::Surface gains a crate-internal as_raw.

The audit is outstanding: this session wrote the change and must not audit
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/appkit.rs: Window::open, size, next_event, draw, set_title and dpi as on
Windows, plus show_page/hide_page for the page's IOSurface, activate and
close. Everything runs on the main thread and polls AppKit's queue; size,
scale and closing are polled as on Windows. Two layers in a layer-hosting
content view: the page below, clipped to its rectangle, and the host's strip
above. The decisions are at the top of the file.

src/nsevent.rs: what an AppKit event means, as pure functions with tests on
recorded events (clicks, wheel and trackpad deltas, keys and typed code
units, which events go back to AppKit, closing, placing layers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/smoke_macos.rs is its own main (harness = false), since AppKit needs
the main thread. It opens a 240 by 160 point window that takes no focus,
checks the first Expose, draws, refuses a wrong length and a NUL title,
reads the dpi, shows and hides a page surface twice the content area,
refuses AppKit off the main thread, closes and sees Event::Closed through
the queue. A 20 s watchdog ends the process. It skips, saying why on
stderr, under Miri, off Apple silicon macOS, and without a window server
session. examples/pattern.rs now runs on macOS too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cargo xtask check` rejects any `admit(` as Verus's proof escape hatch
(CLAUDE.md rule 4), whatever crate it is in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orems' statements

Spec and statements only (CLAUDE.md rule 3); the exec kernel and the proofs
follow. As decided under 5.0 on 2026-11-09, the new path is added beside
Display and Show, which are unchanged.

- Events FromGpu (a frame from the GPU process paired with renderer id),
  Resize and Input; outputs ToGpu and Paint; Policy::Gpu.
- Every renderer is spawned with its GPU process, named by the same id;
  Kill ends both. A cross-origin navigation replaces both and gives the new
  pair the tab's viewport before the commit.
- State gains the tabs' viewports and the renderers' titles (each entry
  tagged with the renderer's lock, forgotten when the renderer goes).
- from_gpu: FrameReady gives Paint { tab, url, title, buffer, width,
  height } from kernel state when the renderer is committed, the buffer is
  in the ring of 3 and the frame fits the viewport; a frame over the
  viewport is dropped (a resize race), anything else disallowed kills the
  pair.
- Statements: paint_only_own_tab, paint_address_bar (the restated
  address-bar theorem's second half), paint_fits_viewport,
  channel_only_own_tab, input_only_to_own_tab, viewport_only_to_own_tab;
  reply_only_to_sender, net_only_to_tab and event_tab restated over the new
  outputs and events; noninterference's domains, purge, addressing and
  relation extended to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(5.21)

227 unsafe blocks (468 lines), of which 40 dispatcher casts: 21 for Metal
and 19 for the window. Was 138 (338 lines) with 21 casts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAZEL_WINDOW_TAKE_FOCUS=1 also runs Window::activate (Regular policy,
activateIgnoringOtherApps, makeKeyAndOrderFront) and checks the window keeps
working. The default run does not take focus and says so on stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sh policy (5.17c)

Proved for every size: the matcher against its statement (matches_any), and
from any state meeting the invariants, the site rule (note_exit_any) and one
tab's step (tab_step_any: invariants kept, the restart bound's step,
demotion by refusal only before GpuReady). Bounded: the version, hex and
drivers parsers on short inputs, and containment over two tabs. Each ran
alone under the 20-minute and 8 GB caps; all passed, none was capped (peak
4.62 GB, policy_contained_any).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (5.15)

Replaces 5.14's table placeholder. grid.rs: HTML §4.9.12.1 forming a table
over the box tree (CSS Tables 3 §3.3.1), colspan clamped to 1000 and rowspan
to 65534, downward-growing cells, then §3.3.2's track merging; at most 65,536
columns. measure.rs: §3.8.2 cell measures, §3.8.3 column measures by increasing
span, GRIDMIN/GRIDMAX and §3.9.1's used width. distribute.rs: an exact running
share, §3.9.3's sizing guesses and excess rules, CSS 2.1 §17.5.2.1 fixed
layout. rows.rs: §17.5.3 row extents and vertical-align, spanning rows,
§3.10.5's table height. The walk (flow/block.rs) sizes the wrapper from its
table, lays each cell out with 5.14's code as a BFC root at its columns'
width, then places cells (extra padding for vertical-align), rows, columns and
their groups. Quirks §3.8, §3.9, §3.10, §3.13. Two ERRATA entries (CSS Tables
3 §3.9.1 table offsets, §3.8.3 zero baseline max-content width).

The golden dumps are updated in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What is built (the blocklist, the checked start, the crash policy for the
kernel), the decisions, the evidence with the Kani and Miri tables, and what
is not done with what each part waits for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table wrapper's children are its captions and its table box, which is
inline-level for inline-table; has_inline_content took that for an inline
formatting context, so the wrapper laid its table out twice (as a line's
atomic and in its flow). The wrapper's children are always in its flow
(CSS 2.1 §17.4). Found by the inline table baseline test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 unit tests in src/table/tests.rs, each expected position worked out by
hand in its comment: the grid (spacing, colspan, track merging, the ERRATA
equal share, rowspan, rowspan=0, header and footer groups, the column bound),
column widths (excess by max-content, percent and pixel columns, between two
guesses with exact rounding, border-box width, min-width, CAPMIN, auto
margins, fixed layout and its excess), heights (each vertical-align, baseline
alignment, row and cell height in both modes, table height to auto rows,
inline table baseline), HTML's row align, the quirks §3.8, §3.9, §3.10 and
§3.13, 100,000 nested tables without recursion, and the steps alone.

The golden dumps change only in documents with tables (23 files): tables are
now laid out as tables. The HN-shaped page (page-table-news.txt) was checked
by hand against the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step harnesses from any state satisfying the invariant (every size): HTML's
forming steps (start_row, place, end_row, end_group), a column's measures
(widen), a row's extent; any input: the colspan/rowspan clamps, a cell's outer
measures, §3.9.1's used width, the table's intrinsic widths, a cell's extra
padding; bounded: a percentage of a length (up to 100% of 2^14 px).

The floors are now exact 64-bit arithmetic by quotient and remainder (no
i128), and a share's weights are capped at 2^31 (larger sums are shifted
down). Removed at the 20 minute cap (CBMC's division): percent_of_any,
share_step_any and its shrunk share_step_small; the running share is not
proved by Kani, and a native test checks both floors against 128-bit
arithmetic. flow::proofs::role_any re-run for the new roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the new events

The exec step implements FromGpu, Resize and Input, the GPU spawn of every
renderer, the viewports and the lock-tagged titles (exec/surfaces.rs), and
is proven equal to the spec. Every existing theorem is re-proven over the
enlarged event and output sets: the noninterference unwinding gains
FromGpu (its renderer's domain, no declassified part), Resize and Input
(kept by every purge, with no new hypothesis); rel gains equal viewports
and equal titles under the observer's locks (surface_rel.rs). New proofs:
theorem_paint_only_own_tab, theorem_paint_fits_viewport,
theorem_channel_only_own_tab (restated over ids before its proof),
theorem_input_only_to_own_tab, theorem_viewport_only_to_own_tab, and the
Paint half of theorem_address_bar_integrity. Verus: 640 verified, 0
errors. Five existing mutations' find strings follow the code they mutate
(their expect is unchanged); eight new ones. The host shell starts no GPU
process in text mode. README for the crate, with the decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e their lemma fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r again

Its find text also matched the new is_from_gpu's last arm, which comes
first now; it names is_from_renderer's own arm instead. expect unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, StyleFailed

Tags 14 and 110 to 113, none reused or shared with 5.18's. A renderer's
request id names its own fetch, not an identity. Spec only (rule 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audited by a session that wrote no part of clazel-sys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
atiernan and others added 20 commits November 13, 2026 05:50
The host shell now carries out SubConnect, SubSend and SubClose on the same
network Driver as a navigation's connections, and turns a sub-fetch
connection's reports into SubNet. clazel-net's SubConns numbers the kernel's
(renderer, serial, hop) names from 2^63 up (the kernel's navigation
connections count from 0) and maps reports back; it decides nothing.
clazel-net gains clazel-kernel (ours) for the records. The host shell
(crates/clazel/src) is 292 lines, under its limit of 300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GPU process reads the kernel's channel and the list socket on two
threads, so a list the renderer wrote after the kernel's Viewport can be
read before the kernel's GpuViewport when both are already waiting (a
process slow to start). It was drawn on a 1 by 1 target and its frame was
lost. Lists are now held while there is no viewport (at most 16, and 256
MiB), and drawn in order, each giving a frame, when one arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d (5.26)

The stand-in that writes a hand-built list now reads the kernel's frames
(Perl, sysread) and writes its list once BodyEnd has arrived, when a
FrameReady is allowed, instead of sleeping a fixed second. Its handle on
the list socket lives as long as the script: a lexical one closed the
socket at the end of the loop iteration, and the GPU process, seeing its
list socket closed, ended before it took the kernel's viewport (traced:
4 runs in 20 lost the frame that way). Measured after both fixes:
a_hand_built_list_reaches_a_paint_under_the_kernels_url passed 20 of 20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…through real processes (5.19, 5.26)

`Host::event` stepped a leading `Tick` and dropped its outputs, so the
kernel's sweep (a timed-out sub-fetch's `SubClose` and `StyleFailed`) never
reached the network or the renderer. Its outputs are now carried out with the
event's. `pump` steps a `Tick` when nothing arrives, so the kernel's clock
moves on an idle page and its 60 s sheet timeout fires.

Tests through the host, the kernel, the real renderer and the real GPU
process against the in-process server (a module of `browse_gpu.rs`, which
shares its support):
- a page styled only by its sheets is painted with them: green from the
  same-origin sheet, blue from the cross-origin one;
- the same-origin sheet request carries the page's cookie, the cross-origin
  one none, and goes to its own host (the server's record);
- a sheet that never answers blocks painting until the host's clock passes
  the kernel's timeout, then the page is painted without it;
- the HN-shaped fixture is served with its sheet, which is asked for and
  changes the frame (against a 404 sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….25)

`Render` for `clazel browse --headless OUT --size 800x600 URL`: each page is
served by the in-process server under its WPT path (WPT's content type by
extension), and the PNG it writes (RGB) is decoded to the runner's RGBA.
A module of `browse_gpu.rs`.

- Plumbing, with pages written here: a green square matches its border-drawn
  reference and mismatches a red one (pass); a test whose match is the red
  square fails with 10 000 pixels different.
- The vendored WPT reftests in the subset (`clazel-style`'s
  `wpt-mediaqueries`, 17) are run and counted, not asserted. Here: 0 pass,
  0 fail, 4 not run (script: `relative-units-001` to `-004`), 13 not run
  because their reference `css/reference/ref-filled-green-100px-square.xht`
  is not vendored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… over three runs (5.25, 5.26)

Stored with its provenance in the fixture's README. Nothing compares against it
yet. CI's lavapipe comparison is a different driver and needs its own image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ces (5.19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Default 30 s as before; the address bar tests use 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Perl stand-in GPU process paired with the real renderer: a frame claimed
larger than the viewport is dropped by the kernel (no Paint, nothing
killed, 5.18's decision); pixels larger than claimed, a header that is not
a buffer, a buffer too small and a truncated one are painted by the kernel
(it cannot see bytes) but never shown, and browse --headless ends with its
error. A URL-shaped title is only Paint.title; a page with -1e9px margins
paints within the content area, whose layer sits below the strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The crash policy (5.17c) is not in the kernel yet, so this records rather
than requires: after the GPU process is killed, the renderer's next list
write fails (broken pipe, exit 70), the kernel drops the tab's renderer,
and nothing restarts either; the host stays up and logs both exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each case through a real sandboxed renderer and GPU process, with its
outcome, a stated time limit, the renderer's peak against item 4.13's and
5.20's terms (plus a stated 64 bytes per byte of style text, which the
README leaves unbounded) and the GPU process's against 1 GiB: CSS bombs in
inline style elements, seven malicious fonts (tests/support/fonts.rs edits
DejaVu Serif), a million elements flat and nested, a million-column table,
colspan=1000 everywhere, and 1e9px font sizes, widths and margins.

Measured on an M1 Pro, debug build, each case alone under an 8 GB and
10-minute cap. Two findings, ignored with their numbers: 10,000 rules over
100,000 elements, and a million nested elements, paint nothing within
600 s. Six more cases that are slow or allocate gigabytes are ignored with
theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (5.26)

Server::kernel now gives the frames of held lists; a viewport with none
held gives none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts to resolve: git merged every file on its own. Brings in the GPU
process's held list (ce60b42), the stand-in renderer waiting for its commit
(9b2c093), browse --headless --wait, and 5.26's hostile-page, address-bar and
GPU-kill tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssed (5.23, 5.26)

`spawn_gpu` and `spawn_with_list` clear a socket's close-on-exec flag, and
every program started before the host's copy is closed inherits it. Their
documentation put the duty on the caller ("the host must start nothing
else"), which the host shell meets with one thread. A process with several
hosts does not: `browse_gpu.rs` runs its tests, each with its own host, on
parallel threads.

Measured on this branch (merged with hostile-pages' held-list fix), 15
parallel runs of `browse_gpu`: 3 failed, with renderers SIGKILLed right after
commit, GPU processes and renderers whose `--list-fd` was not their socket
(`cannot open the list socket N: Permission denied`: a read-only file sat at
that number), and lost frames. 15 runs with `--test-threads 1`: none failed.

So `launch` now takes one process-wide lock (`alone`) for every start, from
clearing the flag to closing the host's copy, and makes it public. The tests'
own starts (`clazel browse --headless`, the fixture's `cargo build`) take it
through `support/gpu.rs`'s `output`. With the lock in `launch` only: 2 of 15
runs failed, both in tests that start `browse` outside it. With the tests'
starts locked too: 1 of 15 (below).

Not fixed: in 1 run of 15, `a_real_page_repaints_for_viewports_and_scrolls_
and_follows_a_click` got commit and `Title`, no process ended, and no `Paint`
came in 20 s. The host's log does not record the GPU process's messages, so
where that frame was lost is not known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The renderer draws a notice for any type but HTML and plain text, and WPT
serves `.xht` and `.xhtml` as `application/xhtml+xml`. So an XHTML test, or a
test whose reference is XHTML, is "not run: XHTML". Decided by the path, so a
reference that is not vendored is judged too, before anything is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`css/reference/ref-filled-green-100px-square.xht` (568 bytes) and
`css/mediaqueries/reference/ref-green-body.xht` (434 bytes), byte for byte
from WPT 4ad507b28db0a636f528def9161d109fe11b93a3, with WPT's licence and their
SHA-256 in `tests/wpt-references/README.md`. They were fetched by another
session with the maintainer's approval, and checked against those hashes here.

They are laid out by WPT path beside the runner, not in `tests/data/`, which
holds only the inputs of `xtask`'s converters. The runner loads them, and the
vendored-reftests test now asserts every reference its 17 tests name is
present.

The counts do not change: both references are XHTML. HTML's navigation hands
an XML MIME type such as `application/xhtml+xml` to its XML document loading,
which needs an XML parser this renderer does not have. So the 13 stay "not run:
XHTML", and the 4 `relative-units` pages "not run: script".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eline (5.26)

The page and its linked sheet from the loopback rustls server the host
tests use (TLS 1.3, ChaCha20-Poly1305, X25519, the clazel-crypto test
certificates): the sheet comes through FetchStyle on its own TLS
connection (SubConnect honours tls), its request carries the cookie the
same-origin page set, and the painted frame has the sheet's colour.

Written while trying the https demo on news.ycombinator.com, which worked
in fetch, headless and window mode with no change: this pins down the
path it took. Nothing fetched from the real site is committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release clazel looks for clazel-gpu and clazel-renderer beside itself in
target/release, but the fallback ran a plain cargo build, which puts them in
target/debug, so the binary it wanted never appeared. Pass --release when the
running binary is in a release directory.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A chevron at the strip's left, drawn by the host without the font: dark when
the tab's list of committed URLs has somewhere to go back to, pale when not.
A click on it goes back as Command-[ does and drops any edit. The field
starts 6 points after it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@atiernan

Copy link
Copy Markdown
Member Author

Part 1: renderer: the graphical pipeline, documents to display lists with Viewport, Input and Title (5.20, before 5.19)

(The building session's description of this part of the branch, posted as a comment for length. Where it says something waits, see the PR description: most of it was done further up the branch. Its statement that the clazel-sys audit is outstanding, if any, is out of date.)

This branch joins the stacks; merge it after all of these:

The merged branches are untouched. CI has not run on this branch or any branch below it. Item 5.19 (claude/style-fetches) is not in it yet: see "What waits".

What

Item 5.20 of docs/phases/phase-5.md: crates/clazel-renderer, new module src/page/ beside pipeline.rs, Tier 0. Branch claude/renderer-pipeline, tip eb8fa5b6.

run_graphics(r, w, list, fonts) reads the kernel's messages, sends Title and Navigate to the kernel, and writes display lists to the GPU process's socket. The steps:

  1. Item 4.13's content type, encodings, parse and base URL. text/plain becomes one pre block; any other type is a drawn notice.
  2. The style sources in tree order (5.8).
  3. The wait for linked sheets (page/sheets.rs). This is the one seam for 5.19, with its contract written in page/state.rs. Today every pending link fails at once, so pages render with style elements, style attributes and the UA sheet.
  4. The cascade (5.7) for the viewport in CSS pixels.
  5. Boxes (5.13).
  6. Layout at the viewport's width (5.14, 5.15), in fonts read before the sandbox (--font PATH).
  7. Paint (5.16) at the viewport's scale.
  8. Per frame, in page/frame.rs:
    • the items that meet the viewport at the scroll offset, offset by it;
    • the glyph masks (5.17) the GPU process does not hold yet, kept within 5.17a's limits across lists;
    • encoded (5.17a) and written as a 4-byte big-endian length and the list.
  9. Title when it changes.

Messages: Viewport lays out and paints again; Input scrolls (clamped to the page) and paints again, or hit-tests a click and sends Navigate.

The text path is unchanged. pipeline.rs and Display stay until 5.26, as 5.18 decided; the renderer binary still runs it (see "What waits"). The renderer stays single-threaded, and the sandbox gains no rule.

New dependencies, all our own crates: clazel-style, clazel-font, clazel-layout, clazel-glyph-mask, clazel-displaylist; clazel-gpu for tests only. No unsafe, no lint override, no tier change, nothing in clazel-sys.

Decisions (maintainer away; recorded in the crate README and src/page/mod.rs)

  • A sibling module rather than pipeline.rs edited in place. The text path stays whole for Phases 2 to 4's tests, and page/ calls its steps 1 to 4.

  • Fonts:

    • --font PATH arguments, read in main.rs before the sandbox is entered; the first face of each file.
    • The bytes are leaked, to live as long as the process.
    • With no face, layout uses em boxes and no text is drawn.
  • text/plain and notices are made into HTML (<pre>, <p>, escaped) and drawn by the same pipeline.

  • The viewport:

    • at most 8,192 device px a side (the GPU process's limit);
    • scale 2, or 1 for any other value;
    • the CSS viewport is the device one divided by the scale;
    • nothing is painted before a viewport, or for a 0 by 0 one.
  • The title is document.title (stripped and collapsed), at most 1,024 characters, sent when it changes.

  • Input: scroll is vertical; a click goes to the topmost link region; a key does nothing yet.

  • Masks stay live in the GPU process across lists under ids the renderer gives:

    • uploaded once;
    • freed only when a frame's new masks would pass 5.17a's limits (65,536 masks, 64 MiB);
    • a frame's fills, clips and glyphs are capped at half of 5.17a's item limit, with the clip kept balanced.
  • The list socket's framing (a 4-byte big-endian length, then one list) is ours: the GPU process's reader does not exist yet.

  • The memory bound is stated in the README with measured sizes. On aarch64, per record:

    Record Bytes
    computed style 432
    box 120
    fragment 96
    line box 56
    glyph 24
    paint item 56
    5.17a item 32

    The computed table at the 4,000,000-node limit is about 1.7 GB. The bound is not measured: 5.26's hostile pages measure it.

Evidence

  • Tests (src/page/tests.rs, 7, plus 9 existing). Each runs documents through run_graphics with in-memory sockets and the vendored DejaVu Serif:
    • A style element and a style attribute to pixels: every list passes clazel-displaylist's Validator (kept across lists, as in the GPU process). Drawn by clazel-gpu's software backend: the body's red at (50,5), the div's blue at (5,5), dark text pixels below. The title "A page" is stripped and collapsed.
    • Viewports lay out again: a narrower one wraps a line lower, and fewer masks are uploaded the second time.
    • Scrolling is clamped: to 450 in a 500px page with a 50px viewport, and back to 0. A scroll that changes nothing sends no list.
    • A click on a link sends Navigate with the URL resolved against the base; a click elsewhere sends nothing.
    • text/plain and an image/png notice are drawn as glyphs.
    • Titles are sent only when they change; nothing is painted before a viewport.
    • The sheet wait seam, with and without fetches: media=print does not block; an answer clears its link.
  • cargo test -p clazel-renderer -j 4: 16 + 1 + 11 + 13 pass. cargo test -p clazel-layout -j 4 --lib: 158 pass. cargo test -p clazel -p clazel-kernel -j 4 (Phases 2 to 4 through the real sandboxed renderer's text path, and the kernel): all pass (2 ignored, as before).
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets --locked -j 4 -- -D warnings, and cargo xtask check (RESULT: STATIC CHECKS PASSED) pass.
  • No Kani or Miri for this item: the renderer is Tier 0, and the brief asked for tests of the pipeline. The kani lock was not taken.

What waits

  • Item 5.19 (FetchStyle, Style*) is not pushed yet, so linked sheets do not load.
    • The seam is SheetWait: start(sources, device, fetch = true, encoding), a FetchStyle per index in pending, answer(index, response, encoding) per reply, and render when ready().
    • Page::load calls start with fetch = false today; run_graphics must also hold a page until it is ready.
    • I will merge claude/style-fetches and finish this when it is pushed.
  • The list socket is not wired, so the renderer binary still runs the text path.
    • The host must create a socket pair at the GPU spawn and pass the renderer its end.
    • Adopting an inherited descriptor and clearing its close-on-exec flag needs a new audited clazel-sys call (phase-5.md, 5.18's plan), outside this item. The renderer has no unsafe to do it.
    • run_graphics is complete and tested with in-memory sockets.
  • The GPU process's list reader (decode, validate, draw per frame from the socket) is not built. The framing it will read is this branch's.
  • The memory bound is stated, not measured (5.26).
  • WPT or reference-page comparisons wait for 5.25.

Verification status

  • cargo xtask verify was not run locally in any form, as instructed.
  • CI has not run. Its proof jobs need a full-verification approval, and the branches listed above must merge first.
  • The Status table, roadmap line, docs/STATUS.md and its SVGs, SPEC-REVIEWS.md, SYS-AUDITS.md, CLAUDE.md and clazel-sys are untouched.

@atiernan

Copy link
Copy Markdown
Member Author

Part 2: clazel browse with a window on macOS, and browse --headless (5.23)

(The building session's description of this part of the branch, posted as a comment for length. Where it says something waits, see the PR description: most of it was done further up the branch. Its statement that the clazel-sys audit is outstanding, if any, is out of date.)

Branch: claude/browse-window, tip 50631b181dad12bfd15fc2d31e125ea0f7405f48.

Stacked. It contains, and must merge after:

  • claude/gpu-sandbox (5.21a, tip 98c053fe), which itself contains claude/mac-window-08480b (5.21, with its clazel-sys audit row c9a3cb8c), claude/gpu-fallback-cfed1c (5.17c) and clazel-gpu: batching, shaders and the Metal backend #153 claude/gpu-drawing-82b82a (5.17b).
  • claude/ipc-surfaces-cfd15e (5.18, tip 301e83fc), which contains the decision commit of docs/phase5-518-decision. The merge had no conflicts, not even in Cargo.lock.

CI has not run. clazel-sys changed (one function); its audit is outstanding (below). The end-to-end test with a real page waits on 5.20 (claude/renderer-pipeline).

What

Item 5.23 of docs/phases/phase-5.md, for macOS on Apple silicon: the host side of clazel browse with a window, complete against the kernel's outputs of 5.18, and browse --headless OUT URL.

  • The GPU process exists now (crates/clazel-gpu/src/main.rs, the clazel-gpu binary). The host starts it at SpawnGpu, in its 5.21a sandbox, paired with the tab's renderer. It draws every list it is sent and answers each with FrameReady.
  • The host shell routes SpawnGpu, ToGpu, FromGpu and Paint (crates/clazel/src/lib.rs, 281 lines, under the 300 limit). The GPU process's plumbing is clazel_renderer::launch::Gpus. Resize and Input need no routing in the shell: the command line sends them through Host::event, which was already public.
  • The window (crates/clazel/cli/window.rs) has two regions:
    • the address bar strip, drawn by the host with clazel-font, clazel-layout's shaping and clazel-glyph-mask (cli/strip.rs), from the Paint's URL or the user's typing, never from a byte the page chose;
    • the content area below it, showing the pixels of the latest Paint, clipped by its layer.
  • Title bar: URL — title, URL first.
  • The loop applies every queued window event and everything the host has queued before it draws one frame.
  • Events and keys, as the item lists them:
    • a resize sends Resize;
    • a click or wheel turn in the content area sends Input, relative to the content area;
    • typing in the address bar and Return navigate (navigate_tab); Escape cancels;
    • Command-L focuses the bar, Command-[ goes back along the committed URLs, Command-R reloads;
    • Space, Page Up/Down and the arrows are sent as Input.
  • browse --headless OUT URL [--size WxH] writes the first frame as a PPM, or a PNG with stored deflate blocks (cli/picture.rs, our own code).
  • --gpu=hardware ignores the blocklist, with a warning (5.17c).
  • browse --text URL is the old text mode. browse URL falls back to it without a window server session or off macOS.

Commits:

  1. Merge of origin/claude/ipc-surfaces-cfd15e.
  2. clazel-sys: macos::clear_close_on_exec (fcntl(fd, F_SETFD, 0)).
  3. clazel-gpu: the GPU process (channel.rs, process.rs, main.rs).
  4. clazel-window: Command on Event::Key, keys.rs, show_page_pixels.
  5. clazel-renderer launch and the shell's routing.
  6. cli/: the window loop, headless, the strip, PNG/PPM; the real-process tests.
  7. A log line naming the Paint the window shows.
  8. READMEs.

Decisions (each also recorded where it is made)

  • How buffers pass on macOS (asked for). 5.17b/5.18's READMEs leave the Mach-port route (IOSurfaceCreateMachPort, posix_spawnattr_setspecialport_np) unbuilt and needing a prototype, and memfd is Linux's.
    • So, until that route exists, the ring is the host's: three slots per GPU process. The GPU process sends each frame's RGBA8 pixels on a surface socket (12-byte header: slot, width, height; checked before allocating, at most 256 MiB), then names the slot in FrameReady.
    • The kernel still decides which slot is shown under which URL. The host shows only a slot a Paint names, and only pixels of the Paint's size. A Paint whose pixels have not arrived yet waits for them; a newer one replaces it.
    • Cost: one copy per frame through a socket into the host. The host composites over white into a CGImage (show_page_pixels), so the IOSurface show_page path of 5.21 is unused for now.
    • Recorded in clazel-gpu/src/channel.rs and its README.
  • How the sockets are inherited. The host makes two socket pairs at SpawnGpu: the list pair (renderer to GPU) and the surface pair (GPU to host). It clears close-on-exec on exactly the ends that one child gets, just before that start, then closes its copies. This is the audited call 5.18 planned.
    • The children get the numbers as --list-fd N and --surface-fd M.
    • Each child opens its socket again through /dev/fd/N before its sandbox, which needs no unsafe (measured: /dev/fd reopens a socket on macOS).
  • The list socket's framing: 4 bytes of length, little-endian, then clazel_displaylist's encoding, at most 256 MiB. IPC's 1 MiB frame is too small for lists with masks.
  • The GPU process draws every list in order, so its masks stay the renderer's.
    • Before the first GpuViewport it draws on a 1 by 1 target and sends no frame.
    • A GpuViewport alone draws nothing: the renderer sends a new list at the new size, since replaying a list would replay its mask uploads.
  • Input encoding (for 5.20 to read):
    • click: kind 0 at content-area pixels;
    • wheel: kind 1, the delta in device pixels, positive down, 48 CSS px a notch;
    • Space, Page Up/Down and the arrows: kind 2 with the DOM keyCode (32, 33, 34, 37 to 40), for the renderer to scroll by its own viewport.
  • Scale sent with Resize is 2 when the backing scale is 1.5 or more, else 1.
  • The strip's font is the first system TrueType face of a fixed list (/System/Library/Fonts/Supplemental/Arial.ttf first). Nothing is bundled. With no font, the strip has no text and the host says so.
  • The URL before the first Paint is drawn grey: it was asked for, not yet committed.
  • browse activates its window (takes focus) as an ordinary app. No committed test does.
  • The shell's line limit. Routing the GPU path pushed src/lib.rs past 300 lines. I moved the GPU process's management into clazel_renderer::launch::Gpus, and moved the log's frame formatter (describe, 22 lines of formatting) there too. This is code moved out of the counted directory to stay under the limit; the shell's decisions did not move.
  • clazel-renderer now depends on clazel-gpu, for channel (the host's reader, and the renderer's write_list in 5.20).

For 5.20 (the renderer pipeline)

When paired with a GPU process, the renderer is started with --list-fd N. It should:

  1. open /dev/fd/N before entering its sandbox;
  2. write each list with clazel_gpu::channel::write_list;
  3. lay out at the size the kernel's Viewport gives.

In text mode no --list-fd is passed. Today's clazel-renderer ignores the argument and sends Display, so browse URL opens the window with an empty page area until 5.20.

Evidence (Apple M1 Pro, macOS 26.7.2)

  • Real processes, crates/clazel/tests/browse_gpu.rs: 3 tests pass. The setup:

    • a stand-in renderer (a shell script) writes a hand-built list to its inherited list socket. The list has fills, a translucent fill under a clip, and a mask upload and draw;
    • the real clazel-gpu, sandboxed, on Metal, draws it.

    What they check:

    • the kernel's Paint names slot 0 under the kernel's URL;
    • the pixels equal clazel-gpu's software reference within one level per channel;
    • clazel browse --headless against a served page writes a PNG and a PPM;
    • each decodes to the reference (the PNG through a decoder that checks every chunk's CRC, the zlib header, every stored block's LEN/NLEN and the Adler-32);
    • each is byte-for-byte what the writer makes of its own pixels.
  • tests/browse_window.rs (harness = false) passes in 1.7 s. It shows that frame in a real 120×100-point window that takes no focus, draws the strip from the Paint's URL, sets the title, sees Closed after closing, and leaves nothing running.

  • A live run of the window loop (clazel browse http://127.0.0.1:…/ with the stand-in renderer, killed after 6 s) logged clazel: showing http://127.0.0.1:18523/ (1024 by 732, slot 0). No GPU process, renderer or server was left running.

  • Unit tests:

    Where Tests
    cli/ (cargo test --bin clazel) 17: the address bar's editing (selection, Backspace, surrogate pairs, control characters never typed, Escape), shortcuts, history, title, wheel, the strip (ink only inside the field, caret, long text, Retina size), PNG/PPM (published CRC-32 and Adler-32 check values, multi-block, a flipped byte fails its CRC, refusals, over-white compositing)
    clazel-gpu 66 + 1: channel round trips and refusals; process ring, refusals, and the whole loop on sockets for every scene against the reference
    clazel-window 27, including the key tables
    clazel-sys 17, including that a cleared descriptor stays open and usable
  • cargo test -j 4 for clazel-sys, clazel-gpu, clazel-window, clazel-renderer, clazel and xtask: every test binary passes, 0 failed (the clazel host tests' 2 ignored tests as before).

  • cargo fmt --all -- --check: clean. cargo clippy --workspace --all-targets --locked -- -D warnings: clean.

  • cargo xtask check: fails on the clazel-sys audit only.

Verification status

  • The clazel-sys audit is outstanding. This session wrote the change and must not audit it.
    • Source hash for the auditor: 18677bf45199e9f02e139956a81413361f4dea2f6e97ae2fbe12cbaf1e58b518.
    • The change: one foreign function, fcntl, added to the macOS list in xtask/src/sys.rs, in src/macos.rs. One unsafe block, F_SETFD with 0 on a BorrowedFd.
    • The README now claims 228 blocks (470 lines).
  • cargo xtask verify --changed was not run, by instruction. No Kani, Miri or Verus run was made, so no lock was taken.
  • CI has not run. Its proof jobs need a reviewer of the full-verification environment.

Not done, unverified, or macOS only

  • Waits on 5.20:

    • a real page through the renderer to the window and the headless file (the end-to-end test);
    • the renderer reading --list-fd;
    • whether 5.20 reads Input as encoded above.
  • No pixel was read back from the screen (Screen Recording permission, and the display was asleep). The strip's and the page's appearance on screen, orientation and clipping are unverified. The live run's screen reported scale 1, so a Retina-scale window was never run; the strip at scale 2 is only unit-tested.

  • The window loop's interaction was not exercised with real input: clicks, wheel, typing and shortcuts were never sent to a live window. Only the pure pieces are tested.

  • macOS only:

    • Linux has no GPU process or clear_close_on_exec (its clazel-sys needs the same call), and no window (5.22).
    • Windows has no GPU process, no Command/Ctrl state (GetKeyState), and browse falls to text mode.
    • Only aarch64-apple-darwin was compiled.
  • 5.17c's host side:

    • the software indicator in the strip;
    • the notice in the content area;
    • --gpu=software, which has no meaning on macOS.

    The kernel does not act on GpuReady, GpuRefused or a GPU process's exit yet; the host logs the exit.

  • The Mach-port route, which would remove the copy per frame.

  • Known weaknesses:

    • A hostile GPU process can make the host allocate up to 256 MiB per surface frame, and show its own tab stale pixels.
    • Clearing close-on-exec lets any program the host starts during that window inherit the socket. The shell spawns from one thread, but parallel in-process tests could leak a descriptor into another test's child.
    • The stand-in renderer sleeps 1 s so the kernel has committed and sized the tab first.
  • Miri was not run on the new tests.

  • docs/phases/phase-5.md was not edited: its Status table, and its 5.23 text saying "memfd", stand.

@atiernan

Copy link
Copy Markdown
Member Author

Part 4: tests: the HN-shaped fixture, geometry dump and comparison, reference script, and the reftest runner's pure parts (5.25, 5.26, partial)

(The building session's description of this part of the branch, posted as a comment for length. Where it says something waits, see the PR description: most of it was done further up the branch. Its statement that the clazel-sys audit is outstanding, if any, is out of date.)

Stacked on claude/renderer-pipeline (5.20, tip eb8fa5b), and so on everything below it: claude/paint-display-list, claude/table-layout-1d3f0b, #154 claude/flow-layout-050856, with claude/ipc-surfaces-cfd15e and claude/gpu-sandbox merged in. Merge after them. Branch claude/reftest-harness, tip 5158380. CI has not run on this branch or any branch below it.

What

The parts of items 5.25 and 5.26 that need neither browse --headless nor real processes, in four commits:

  1. The HN-shaped fixture (5.26, second bullet; 5fe300c): crates/clazel/tests/fixtures/hn/index.html and site.css.
    • A page and a sheet written for this project: MIT licensed, invented text (a made-up link aggregator), nothing copied from the real site or its sheet.
    • Its shapes: quirks mode, tables nested four deep with width/bgcolor/border/cellpadding/cellspacing/align/valign/colspan, spacer images (widths 0, 40, 80), font elements, the inline styles (negative margins included), a form, one same-origin linked sheet with a query, and the sheet's selectors, properties, !important and twelve media queries.
    • Its README has a table of what each shape is there for.
    • It lives in tests/fixtures/, not tests/data/, which holds vendored suites that need converters.
  2. Our side of the geometry comparison (5.25; 876ecfe): clazel-layout's new rects module.
    • Every element's and text node's client rectangles from the fragment tree: border boxes, inline pieces per line, a table's wrapper, and a text node's glyphs per line over its font's content area.
    • Each is keyed by the node's childNodes path, and written as JSON in one schema, clazel-client-rects/1, stated once in the module's header. Our own reader reads it back.
    • The comparison is a pure function: rectangles matched by node, kind and index; within 2 px in position and 2 % in width (two constants, stated once); reporting the count outside, plus missing and extra rectangles and examples.
    • Page::client_rects in the renderer lays out exactly as run_graphics does.
  3. The reference script (5.25; 714b6a4): scripts/reference-rects.mjs.
    • Pinned headless Chrome (Chrome for Testing 131.0.6778.85, puppeteer-core 23.10.4) at 800×600, scale 1, with the repository's font files as the generic families (a fontconfig file listing only them, and Page.setFontFamilies).
    • It records the same JSON from getClientRects, with the browser's name, version and date, and a viewport screenshot.
    • It has never been run. No browser was downloaded or started; its header says so.
  4. The reftest runner's pure parts (5.25; 5158380): crates/clazel/tests/reftest/ and crates/clazel/tests/reftests.rs, against WPT at this repository's pinned commit 4ad507b2 (the docs' "Fuzzy Matching" and "Multiple References", tools/manifest/sourcefile.py, wptrunner's executors/base.py):
    • <link rel=match|mismatch> and <meta name=fuzzy> found with our own HTML parser: named or positional ranges, N or N-M, a reference key before the last :, and WPT's error cases.
    • The pixel comparison as wptrunner runs it: RGB only, the difference counted over pixels whose luminance (PIL's convert("L")) is not zero, and check_pass's rule with its two shortcuts. The docs' formula differs (it counts every pixel), and this difference is listed.
    • "Not run, by reason": print, script (including reftest-wait), images and embedded documents (elements and url(), and CSS outside the subset (by clazel-style's own property table and grammars, the "parsed, no effect" properties, and ignored at-rules).
    • A runner that takes the renderer as a parameter (trait Render), with WPT's multiple-references rule.

Decisions

  • The fixture's shapes come from phase-5.md's measured "What the exit page needs" (2026-10-31). Item 5.1's inventory script has never been run, so docs/phase5-inventory.md does not exist. The fixture's README says so; rerunning 5.1 may add shapes.
  • The JSON schema keys rectangles by childNodes paths (both engines parse with HTML's parser) and uses CSS px in document coordinates. A table element's rectangle is its wrapper's. Heights are not compared, because engines take a line's content area from different font tables.
  • wptrunner's comparison is followed over the docs' formula, because wptrunner is what judges reftests. Alpha is dropped; screenshots of different sizes are unequal.
  • One level of references: a reference's own references are not followed.
  • The "outside the subset" check is conservative. A value the subset's grammar refuses counts as not run, which also catches some error-handling tests that would pass.
  • Where things live:
    • the dump is in clazel-layout, which knows the fragment tree;
    • the runner's parts are test support in the clazel crate's tests/ (the plan's reftests.rs), so the host's src/ line count is unchanged;
    • new clazel dev-dependencies (ours): clazel-treebuilder, clazel-style.
  • Nothing from WPT is vendored. No listed directory was needed to write or test these parts.

Evidence

  • clazel-layout's rects tests (4):
    • a hand-built fragment tree (a real page's boxes and document, its fragments written by hand) gives the expected four rectangles;
    • the JSON round trip, including a browser's floats rounding to 1/64 px;
    • the tolerance at its edges, with zero width;
    • the report's counts.
  • The renderer's fixture test: the fixture through the pipeline's layout at 800×600 gives over 100 rectangles and over 50 text rectangles.
    • The outer table is the larger of 85% of 784 px and its min-content width. With no sheet loaded (5.19), that is exactly the 80-column textarea's width, 839.05 px.
    • The JSON reads back unchanged, and compared with itself, every rectangle is within the tolerance.
  • reftests.rs (7):
    • references found and resolved;
    • every fuzzy form and error;
    • fuzzy per reference;
    • wptrunner's difference on hand-made pixel buffers (a red difference of 1 alone is not counted; luminances worked out in the comments);
    • the fuzzy rule and its shortcuts;
    • every not-run reason, with an in-subset page that is run;
    • the runner with a fake renderer: one of two matches passes, a matching mismatch fails, a failing match reports its difference, not-run, not a reftest, a missing file.
  • Run with -j 4: cargo test -p clazel-layout -p clazel-renderer (all pass); cargo test -p clazel --test reftests (7 pass); cargo fmt --all -- --check; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo xtask check (RESULT: STATIC CHECKS PASSED).
  • No Kani or Miri (test support and a Tier 0 dump). The lock was not taken.

What waits

  • 5.25's reftests themselves:
    • they need browse --headless to plug in as the Render (5.23, on claude/browse-pipeline);
    • and WPT's css/CSS2/ directories and css-tables vendored (5.1).
    • No WPT test has been run or counted; the README's conformance row waits.
  • CI's lavapipe job (one pinned Mesa, failing on any other driver), Windows on WARP, and the macOS smoke test.
  • The stored screenshot of the fixture, compared exactly in CI: it needs the GPU path drawing the fixture headless.
  • The reference engine's numbers: the script has never been run, so no reference JSON or screenshot exists and no geometry comparison against a browser has been made.
  • 5.26's process tests:
    • the fixture served by the in-process server;
    • its sheet through FetchStyle with the cookie, and a cross-origin sheet without it;
    • Paint's buffer against the stored screenshot;
    • the address bar;
    • a click navigating.
  • 5.26's hostile pages (CSS bombs, malicious fonts, huge tables and lengths, attempts on the address bar, GPU process kills), the demo, and keeping Phases 2 to 4's tests on the new schema.
  • The fixture's sheet is linked, so until 5.19's fetches are wired the pipeline lays the fixture out without it.

Verification status

  • cargo xtask verify was not run locally in any form, as instructed.
  • CI has not run. Its proof jobs need a full-verification approval, and the branches below must merge first.
  • The Status table, roadmap line, docs/STATUS.md and its SVGs, SPEC-REVIEWS.md, SYS-AUDITS.md, CLAUDE.md and clazel-sys are untouched.

atiernan and others added 3 commits November 13, 2026 16:29
Off macOS the window loop is not compiled, so the address bar, history,
scroll helpers and the whole strip were dead code in the clazel binary and
clippy (-D warnings) failed on Linux and Windows. They are now built on
macOS on Apple silicon and under test; input, STRIP_POINTS, FONT_PATHS and
system_font, which no test uses, on macOS only.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
clazel opens a window with no page and the address bar focused; the first
URL typed opens the tab. clazel URL opens the window at URL. The browse
argument is gone: --text, --headless OUT, --size, --wait and --gpu=hardware
are options of clazel itself. Text mode and --headless still need the URL.
clazel URL SCRIPT, demo and fetch are unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…s' fonts

The Miri job failed twice:
- `clazel`'s strip tests read DejaVu Serif with `std::fs::read`, and
  Miri's isolation refuses `open`. They now embed it with `include_bytes!`.
- `clazel-renderer`'s page tests built `Fonts` from a copy of the embedded
  font, which `from_bytes` leaks with `Box::leak`; Miri reported 16 leaks.
  The new `Fonts::from_static` takes bytes that already live for the
  process, and the tests pass the embedded bytes to it. `from_bytes` (the
  production path, one leak per file at startup) now goes through it.

Under Miri locally: the six strip tests and `page::tests::the_sheet_wait`
pass with no leak reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@atiernan
atiernan deployed to full-verification November 14, 2026 00:32 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
full-verification — 000bc94c Deployed Nov 14, 2026 by atiernan via Approve the proofs #452
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant