Skip to content

Commit 9c0e306

Browse files
authored
Merge pull request #193 from justrach/release/0.4.13
release: v0.4.13 — routes that fabricated their answers
2 parents 08da605 + 88a7025 commit 9c0e306

23 files changed

Lines changed: 5708 additions & 931 deletions

File tree

.claude/skills/kuri-ios/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ Drive iOS Simulators (and, where possible, real iPhones) through the
1010
`kuri-mobile/src/ios/` and the main `kuri` binary forwards
1111
`kuri ios …` to the `kuri-mobile` binary.
1212

13+
> ⚠️ **Host cursor & keyboard warning.** On the Simulator, `tap`, `doubletap`,
14+
> `longpress`, `swipe`/`pan`, and `type` post **real CGEvents into the macOS
15+
> WindowServer** — they physically move your mouse pointer and seize keyboard
16+
> focus for the duration of the gesture. Do **not** run them while you are using
17+
> the machine, or from an unattended/background job; a stray click lands wherever
18+
> the pointer happens to be. Cursor-free, always-safe commands: `list-devices`,
19+
> `boot`, `shutdown`, `openurl`, `launch`, `terminate`, `list-apps`,
20+
> `screenshot`. An isolated input backend that bypasses the host pointer is
21+
> tracked in issues #180 and #183.
22+
1323
## When to use this skill
1424

1525
- Boot an iOS Simulator, open Safari to a URL, screenshot the result.

.claude/skills/kuri-mobile/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ Inspired by [`mobile-device-mcp`](https://github.com/srmorete/mobile-device-mcp)
1111
reimplemented in Zig with no Bun, Node, Gradle, or Xcode in the
1212
build path.
1313

14+
> ⚠️ **iOS Simulator input seizes the host cursor.** `kuri ios tap/swipe/type`
15+
> (plus doubletap/longpress) post real CGEvents into the macOS WindowServer, so
16+
> they move your physical mouse and grab keyboard focus. Don't run them while
17+
> you're working or from a background job. `kuri android` input runs on-device
18+
> via adb and does **not** touch the host cursor. Tracked: #180, #183.
19+
1420
**Use the platform-specific skills** for actual work:
1521

1622
- **`kuri-ios`** — iOS Simulator (boot, openurl, launch, terminate,

.claude/skills/kuri-server/SKILL.md

Lines changed: 104 additions & 3 deletions
Large diffs are not rendered by default.

.devin/skills/kuri-ios/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ Drive iOS Simulators (and, where possible, real iPhones) through the
1010
`kuri-mobile/src/ios/` and the main `kuri` binary forwards
1111
`kuri ios …` to the `kuri-mobile` binary.
1212

13+
> ⚠️ **Host cursor & keyboard warning.** On the Simulator, `tap`, `doubletap`,
14+
> `longpress`, `swipe`/`pan`, and `type` post **real CGEvents into the macOS
15+
> WindowServer** — they physically move your mouse pointer and seize keyboard
16+
> focus for the duration of the gesture. Do **not** run them while you are using
17+
> the machine, or from an unattended/background job; a stray click lands wherever
18+
> the pointer happens to be. Cursor-free, always-safe commands: `list-devices`,
19+
> `boot`, `shutdown`, `openurl`, `launch`, `terminate`, `list-apps`,
20+
> `screenshot`. An isolated input backend that bypasses the host pointer is
21+
> tracked in issues #180 and #183.
22+
1323
## When to use this skill
1424

1525
- Boot an iOS Simulator, open Safari to a URL, screenshot the result.

.devin/skills/kuri-mobile/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ Inspired by [`mobile-device-mcp`](https://github.com/srmorete/mobile-device-mcp)
1111
reimplemented in Zig with no Bun, Node, Gradle, or Xcode in the
1212
build path.
1313

14+
> ⚠️ **iOS Simulator input seizes the host cursor.** `kuri ios tap/swipe/type`
15+
> (plus doubletap/longpress) post real CGEvents into the macOS WindowServer, so
16+
> they move your physical mouse and grab keyboard focus. Don't run them while
17+
> you're working or from a background job. `kuri android` input runs on-device
18+
> via adb and does **not** touch the host cursor. Tracked: #180, #183.
19+
1420
**Use the platform-specific skills** for actual work:
1521

1622
- **`kuri-ios`** — iOS Simulator (boot, openurl, launch, terminate,

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,39 @@
22

33
All notable changes to kuri are documented here.
44

5+
## [0.4.13] — 2026-07-26
6+
7+
Seven HTTP routes answered with results they never obtained from the browser. Each returned `200` with a well-formed body, so no caller could distinguish "Chrome reported nothing" from "kuri never asked Chrome" — the failure mode that costs the most, because it looks exactly like success.
8+
9+
### Fixes — routes that fabricated their answers
10+
11+
- **`/react/tree` reported `{"react":false}` on every React page.** React double-buffers fibers, and the `__reactContainer$…` property is stamped on the host node at mount and keeps pointing at whichever fiber existed *then*. After the first commit that is the off-screen side of the pair, whose `.child` is `null` — so detection concluded there was no React at all. It now follows whichever side actually rendered, via `.alternate`. Confirmed against React 19.2.5, where the mount-time fiber has `hasChild:false` while its alternate has children; the route now returns a real tree with `FunctionComponent`, `Context.Consumer` and `HostComponent` nodes at their true depths
12+
- **`/trace/stop` returned an empty acknowledgement.** `Tracing.start` never asked for `transferMode: ReturnAsStream`, so Chrome kept the trace to itself and there was no stream handle to drain — the route reported success and wrote nothing, every time. `IO.read`/`IO.close` now drain the stream; a routine page load yields ~400 KB of real trace on disk
13+
- **`/network` returned a canned `{"status":"ok"}`.** No request was ever recorded. Now a bounded ring of real requests carrying url, method and MIME type
14+
- **`/mainframe` returned the same hard-coded string to every caller**, regardless of tab or page. Now the actual `frameId`, `loaderId` and `url` from `Page.getFrameTree`
15+
- **`/expose` bindings fired into the void.** The binding was registered with Chrome but its invocations were never captured, so `/expose/calls` stayed empty no matter how many times the page called it. Payloads are now recorded with the call
16+
- **`/screencast/stop` returned a canned ok** without ever reporting what it had captured. Now reports the real `frame_count`
17+
- **`/console` and `/errors` had regressed to returning empty** on any page, having lost their collector
18+
19+
### Fixes — script injection
20+
21+
- **`/evaluate` and `/evalhandle` JSON-escaped the expression twice.** The expression arrives already escaped; escaping it again turned a newline into `\n` and then `\\n`, which decodes back to a literal backslash-n *in the JavaScript source*. Chrome answered every multi-line or quote-containing expression with `SyntaxError: Invalid or unexpected token` — the two routes whose entire purpose is running arbitrary JS could not run any non-trivial JS. Escaped exactly once now
22+
- **`stealth.js` polluted every page's error stream.** The file was a top-level script containing `const originalQuery`, but it is injected twice by design — once via `Page.addScriptToEvaluateOnNewDocument`, once evaluated into the already-loaded page. Re-running it in the same global, which happens whenever tab discovery re-runs against a live tab, re-declared the const. The resulting `SyntaxError` aborted the whole re-injection *at parse time*, so none of the stealth measures after it applied, and it surfaced in the page's own error stream, meaning `/errors` reported a kuri-internal failure as if it were the page's. Now an IIFE behind an idempotence guard, which also stops the permissions wrapper being wrapped around itself. `/errors` on a clean page is `[]`
23+
24+
### Fixes — memory safety
25+
26+
- **The event rings handed out pointers into storage they were about to overwrite.** A snapshot borrowed the ring's own buffers, so the next event that wrapped the ring rewrote memory the caller was still reading — a use-after-free reachable from ordinary traffic. Every ring (`RequestRing`, `ScreencastRing`, `BindingCallRing`, `NetworkRing`) now dupes into a caller-supplied per-request arena, and the contract is stated once and referenced from each
27+
- **`extractObject`/`extractField` were blind to JSON escaping.** They scanned for the closing brace or quote without honouring `\"`, so a string value containing an escaped quote was truncated there, and a value with unbalanced literal braces — a GraphQL query sent over GET, for instance — ended the object early. Both now respect escapes, with regression tests for each case
28+
29+
### Performance
30+
31+
- **Route dispatch is ~13× faster.** `route()` was a linear chain of 148 `std.mem.eql` comparisons, so a route's cost depended on where it sat in the chain and every unknown path paid all 148. It is now a comptime `StaticStringMap` (bucketed by length) feeding an exhaustive switch: ~81ns → ~6ns on average, and ~150ns → ~15ns worst case. `zig build bench` now carries a permanent regression guard that dispatches all 148 routes, so a relapse to a linear chain shows up as a benchmark regression rather than a slow surprise
32+
- **`CdpClient` is 62 KiB per tab, down from 520 KiB — 88% smaller.** `ws_read_buf` was a 512 KiB inline array that only ever held the one-shot HTTP upgrade response and RFC 6455 control-frame scratch — message bodies go through `receiveMessageAlloc`, which allocates its own buffer and never touches it — so ~97% of it was headroom nothing in the codebase could reach; it is now 16 KiB. `ws_write_buf` had no readers anywhere and is gone, along with `WebSocketClient.write_buf`, since `writeFrame` builds frames in its own stack buffers
33+
34+
### Tests
35+
36+
378 unit tests in the main suite (plus 76 for `kuri-fetch` and 22 for `kuri-browse`), including a regression test for each fix above. Verified live against Chrome: intercept, dialogs, storage and clipboard routes show no regression from the dispatch rewrite.
37+
538
## [0.4.12] — 2026-07-25
639

740
A hotfix for two problems that made the *installed* product diverge from the built one.

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri,
3-
.version = "0.4.12",
3+
.version = "0.4.13",
44
.dependencies = .{
55
.quickjs = .{
66
.url = "https://github.com/mitchellh/zig-quickjs-ng/archive/main.tar.gz",

kuri-mobile/build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri_mobile,
3-
.version = "0.4.12",
3+
.version = "0.4.13",
44
.dependencies = .{},
55
.fingerprint = 0x41cfa5929f72d020,
66
.minimum_zig_version = "0.17.0-dev.813+2153f8143",

kuri-mobile/src/main.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ pub fn main(init: std.process.Init.Minimal) !void {
5353
return;
5454
}
5555
if (std.mem.eql(u8, sub, "--version")) {
56-
try writeStdout("kuri-mobile 0.4.12\n");
56+
try writeStdout("kuri-mobile 0.4.13\n");
5757
return;
5858
}
5959

src/bench.zig

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ const validator = @import("crawler/validator.zig");
77
const bridge_mod = @import("bridge/bridge.zig");
88
const middleware = @import("server/middleware.zig");
99
const cdp_client = @import("cdp/client.zig");
10+
const router = @import("server/router.zig");
11+
12+
/// Comptime string repeat. This Zig dev snapshot's tokenizer no longer emits
13+
/// a combined `**` token for the array/string repeat operator (`**` lexes as
14+
/// two separate `*` tokens, tripping the "ambiguous binary operator
15+
/// whitespace" check no matter how it's spaced) -- pre-existing breakage in
16+
/// this file, unrelated to router/CDP work, fixed here only so `zig build
17+
/// bench` runs at all.
18+
fn repeatStr(comptime s: []const u8, comptime n: usize) []const u8 {
19+
comptime {
20+
var result: []const u8 = "";
21+
for (0..n) |_| result = result ++ s;
22+
return result;
23+
}
24+
}
1025

1126
// ── Benchmark harness ──────────────────────────────────────────────────
1227

@@ -62,7 +77,8 @@ const Bench = struct {
6277
};
6378

6479
fn fmtDuration(ns: u64) [12]u8 {
65-
var buf: [12]u8 = .{' '} ** 12;
80+
var buf: [12]u8 = undefined;
81+
@memset(&buf, ' ');
6682
if (ns < 1_000) {
6783
_ = std.fmt.bufPrint(&buf, "{d:>7}ns ", .{ns}) catch {};
6884
} else if (ns < 1_000_000) {
@@ -78,10 +94,10 @@ fn fmtDuration(ns: u64) [12]u8 {
7894
// ── Benchmark data ─────────────────────────────────────────────────────
7995

8096
const sample_html_small = "<html><head><title>Test</title></head><body><h1>Hello</h1><p>World</p></body></html>";
81-
const sample_html_medium = "<html><body>" ++ "<div class=\"item\"><h2>Title</h2><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p><a href=\"https://example.com\">Link</a></div>" ** 50 ++ "</body></html>";
97+
const sample_html_medium = "<html><body>" ++ repeatStr("<div class=\"item\"><h2>Title</h2><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p><a href=\"https://example.com\">Link</a></div>", 50) ++ "</body></html>";
8298

8399
fn makeLargeHtml() []const u8 {
84-
return "<html><body>" ++ "<div class=\"item\"><h2>Title</h2><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul><a href=\"https://example.com\">Link</a></div>" ** 200 ++ "</body></html>";
100+
return "<html><body>" ++ comptime repeatStr("<div class=\"item\"><h2>Title</h2><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul><a href=\"https://example.com\">Link</a></div>", 200) ++ "</body></html>";
85101
}
86102

87103
fn makeA11yNodes() [200]a11y.A11yNode {
@@ -289,6 +305,60 @@ fn benchArenaAllocReset() void {
289305
}
290306
}
291307

308+
// ── Route dispatch: the production `router.route_table`
309+
// (StaticStringMap(Route) + exhaustive switch in `route()`). Permanent
310+
// regression-guard benchmark -- if route dispatch ever regresses back to a
311+
// linear if/else-if chain, "all 148 routes/call" here will jump from
312+
// ~1µs back toward ~12µs (measured before/after during the StaticStringMap
313+
// migration; see the optimization task report for the full comparison
314+
// against the old chain).
315+
const bench_route_paths = [_][]const u8{
316+
"/health", "/tabs", "/page/info", "/discover", "/navigate",
317+
"/snapshot", "/action", "/text", "/screenshot", "/evaluate",
318+
"/browdie", "/har/start", "/har/stop", "/har/status", "/har/replay",
319+
"/close", "/cookies", "/cookies/clear", "/cookies/set", "/storage/local",
320+
"/storage/session", "/storage/local/clear", "/storage/session/clear", "/get", "/back",
321+
"/forward", "/reload", "/diff/snapshot", "/emulate", "/geolocation",
322+
"/upload", "/session/save", "/session/load", "/auth/profile/save", "/auth/profile/load",
323+
"/auth/profile/list", "/auth/profile/delete", "/auth/extract", "/debug/enable", "/debug/disable",
324+
"/screenshot/annotated", "/screenshot/diff", "/screencast/start", "/screencast/stop", "/video/start",
325+
"/video/stop", "/console", "/intercept/start", "/intercept/stop", "/intercept/requests",
326+
"/intercept/rules", "/intercept/rules/clear", "/markdown", "/links", "/pdf",
327+
"/dom/query", "/dom/html", "/cookies/delete", "/headers", "/script/inject",
328+
"/stop", "/scrollintoview", "/drag", "/keyboard/type", "/keyboard/inserttext",
329+
"/keydown", "/keyup", "/wait", "/tab/current", "/tab/new",
330+
"/tab/close", "/highlight", "/errors", "/set/offline", "/set/media",
331+
"/set/credentials", "/find", "/trace/start", "/trace/stop", "/profiler/start",
332+
"/profiler/stop", "/inspect", "/window/new", "/session/list", "/set/viewport",
333+
"/set/useragent", "/dom/attributes", "/frames", "/network", "/perf/lcp",
334+
"/ws/start", "/ws/stop", "/batch", "/element/state", "/find-element",
335+
"/dialog/auto", "/dialog/accept", "/dialog/dismiss", "/mouse/move", "/mouse/down",
336+
"/mouse/up", "/mouse/wheel", "/page/state", "/clipboard/read", "/clipboard/write",
337+
"/clear", "/boundingbox", "/wait/function", "/response/body", "/setcontent",
338+
"/selectall", "/setvalue", "/timezone", "/locale", "/permissions",
339+
"/tap", "/dispatch", "/download", "/addstyle", "/bringtofront",
340+
"/pushstate", "/expose", "/expose/calls", "/multiselect", "/swipe",
341+
"/vitals", "/frame", "/mainframe", "/getattribute", "/inputvalue",
342+
"/react/tree", "/react/inspect", "/react/renders", "/react/suspense", "/recording/start",
343+
"/recording/stop", "/request/detail", "/wait/download", "/initscript/remove", "/evalhandle",
344+
"/diff/url", "/cache/set", "/cache/get", "/cache/clear", "/cache/list",
345+
"/screenshot/som", "/snapshot/changes", "/recording/export",
346+
};
347+
348+
fn benchRouteDispatchNewFirst() void {
349+
std.mem.doNotOptimizeAway(router.route_table.get(bench_route_paths[0]));
350+
}
351+
fn benchRouteDispatchNewLast() void {
352+
std.mem.doNotOptimizeAway(router.route_table.get(bench_route_paths[bench_route_paths.len - 1]));
353+
}
354+
fn benchRouteDispatchNewMiss() void {
355+
std.mem.doNotOptimizeAway(router.route_table.get("/nonexistent/route/xyz"));
356+
}
357+
fn benchRouteDispatchNewAvgAll148() void {
358+
for (bench_route_paths) |p| std.mem.doNotOptimizeAway(router.route_table.get(p));
359+
}
360+
361+
// ── Main ───────────────────────────────────────────────────────────────
292362
// ── Main ───────────────────────────────────────────────────────────────
293363

294364
pub fn main() !void {
@@ -334,5 +404,11 @@ pub fn main() !void {
334404
Bench.run("RequestTimer ×100", iters, benchRequestTimer).print();
335405
Bench.run("arena alloc+reset ×100", iters, benchArenaAllocReset).print();
336406

407+
std.debug.print("\n── Route dispatch (StaticStringMap + enum switch) ─────────────────────\n", .{});
408+
Bench.run("dispatch: first route (/health)", iters, benchRouteDispatchNewFirst).print();
409+
Bench.run("dispatch: last route (/recording/export)", iters, benchRouteDispatchNewLast).print();
410+
Bench.run("dispatch: 404 miss", iters, benchRouteDispatchNewMiss).print();
411+
Bench.run("dispatch: all 148 routes/call", iters, benchRouteDispatchNewAvgAll148).print();
412+
337413
std.debug.print("\n", .{});
338414
}

0 commit comments

Comments
 (0)