From 60daddf0a9477c32ed51937845e792f9229fdda4 Mon Sep 17 00:00:00 2001 From: blasty Date: Thu, 9 Jul 2026 11:35:24 +0200 Subject: stress paging on 10k-func binary; document scale constraints Measured against libcrypto.so.3 (10,092 funcs, biggest 52,120 insns): - list_* count cap ~700, disasm max_instructions cap ~500, then SILENT collapse to 10 (not clamped) -> must clamp client-side (use 500). - pagination must advance by len(data); next_offset=offset+count skips data. - disasm offset paging is O(offset): 6ms@0 -> 180ms@50k, no resumable cursor -> domain layer must cache windows + prefetch + over-fetch. - include_total scans whole func (~217ms on monster) -> fetch once, cache. - decompile hard-fails on huge funcs as a soft error (code=null) -> handle. - idb_open needs a writable path for the .i64. tests/stress_paging.py: durable paging benchmark harness docs/PAGING_FINDINGS.md: constraints that drive the paging layer --- docs/PAGING_FINDINGS.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/PAGING_FINDINGS.md (limited to 'docs') diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md new file mode 100644 index 0000000..1c7abff --- /dev/null +++ b/docs/PAGING_FINDINGS.md @@ -0,0 +1,101 @@ +# Paging & scale findings (the "millions of lines" problem) + +Measured against a real target: `libcrypto.so.3` (5.7 MB, **10,092 functions**, +biggest function **52,120 instructions**). These constraints drive the domain / +paging layer. Reproduce with `tests/stress_paging.py --db `. + +## Response shape (list_* / *_query tools) + +A query tool payload is a *list of per-query results*: + +```json +{ "result": [ { "data": [ ... ], "next_offset": } ] } +``` + +## Per-call caps are silent and dangerous + +Every `count` / `max_instructions` parameter is honored up to a cap, then +**silently collapses to a tiny default (10)** — it does NOT clamp to the max. + +| tool | param | honored up to | above cap | +|-------------|------------------|---------------|-----------| +| `list_funcs`| `count` | ~700 | → 10 | +| `disasm` | `max_instructions`| ~500 | → 10 | + +**Rule:** clamp page sizes client-side. Use `list` page ≤ **500**, disasm window +≤ **500**. Never pass a large count hoping for clamping. + +## Pagination: advance by `len(data)`, NOT `next_offset` + +`next_offset` is just `offset + count`. If `count` exceeds the cap you get 10 +rows but `next_offset` still jumps by `count`, **skipping ~99% of the data**. +Correct enumeration: + +```python +offset = 0 +while True: + data = call("list_funcs", queries=[{"offset": offset, "count": 500}])[...] + yield from data + if len(data) < 500: # short page => end + break + offset += len(data) # advance by what we actually got +``` + +Full 10,092-function enumeration = 21 pages, **~3.1 s** (0.31 ms/func). Do this +once in the background with a progress indicator; page lazily otherwise. + +## disasm offset paging is **O(offset)** — the main scroll hazard + +`disasm addr=F offset=N` re-walks from the function start, so latency grows with +depth (window = 60 instrs): + +| offset | latency | +|--------|---------| +| 0 | ~6 ms | +| 1,000 | ~10 ms | +| 5,000 | ~23 ms | +| 20,000 | ~75 ms | +| 50,000 | ~180 ms | + +There is **no resumable cursor**: the payload's `cursor: {"next": N}` is just +informational; `disasm` has no cursor input param. So O(offset) is unavoidable +via this API. + +Implications for the domain/paging layer: +- **Cache fetched windows** by `(func, offset)`. Re-scrolling a deep window + currently re-pays the full ~180 ms (no server-side cache); our cache makes + revisits instant. +- **Prefetch ahead** in background threads (client is concurrency-safe) so the + next window is warm before the user reaches it. +- **Over-fetch** up to the ~500 cap per call and slice locally for the viewport, + amortizing both RTT and the O(offset) walk during fast scrolling. +- **This only bites pathological functions.** The two 52k-insn outliers are + almost certainly mis-analyzed data/unrolled tables; the next largest real + function is 2,748 instrs (deepest offset ≈ 10 ms). Typical scrolling is fine. + +## `include_total` is expensive — fetch once, cache + +Computing `total_instructions` scans the whole function: **~217 ms** on the 52k +monster (vs ~10 ms without). Fetch total once when a function view opens (to size +the scrollbar) and cache it; never request it per-scroll. + +disasm totals are **top-level** fields, not under `asm`: +`total_instructions`, `instruction_count`, `cursor`. + +## Decompile: can hard-fail on huge functions (soft error) + +`decompile` on the 52k-insn function returns, in ~3 ms: +`{"code": null, "error": "Decompilation failed at 0x3349c0"}` with +`isError == false`. The client surfaces this as **data, not an exception** +(correct). The pseudocode view must handle "decompilation failed" gracefully — +fall back to the disassembly view or show an error panel. + +Normal decompile bodies are server-truncated with a `[N chars total]` marker +(still to be solved for full-body display — see Phase 2). + +## Writable path requirement (operational) + +`idb_open` writes the `.i64` next to the input binary, so the path must be +**writable**. Opening from read-only dirs (e.g. `/usr/lib`) fails with +`"Failed to open database"`. Copy targets into a writable dir first +(`targets/` in this repo). -- cgit v1.3.1-sl0p