Skip to content

Stabilization pass toward 0.12.0 - #5

Merged
harshithsunku merged 11 commits into
masterfrom
stabilize-0.12.0
Sep 14, 2026
Merged

harshithsunku merged 11 commits into
masterfrom
stabilize-0.12.0

Conversation

@harshithsunku

@harshithsunku harshithsunku commented Sep 14, 2026

Copy link
Copy Markdown
Owner

A stabilization pass over the whole project toward 0.12.0: agent, transport, server, UI, then docs, hardware and the release. No new features. The wire protocol is unchanged, so a 0.11.0 server drives this agent and a 0.12.0 server drives a 0.11.0 agent.

What changed, by phase

  • Agent correctness (sixth unfreeze). Chunks over the 64 MB cap were dropped silently; they now flush on size at a sample boundary. perf stat rounds run back to back instead of covering half of each interval one chunk late. stop no longer hangs on a child that ignores SIGTERM: every perf child gets its own process group and is reaped SIGTERM then SIGKILL. Also: send timeouts, close-on-exec, LC_ALL=C for perf, a 64 KB pre-auth frame cap, escaped and validated ids, nice 5 for perf script in rounds mode, and monotonic deadlines.
  • Agent responsiveness. The capability probe runs on the collection thread, so ping, status and stop answer during it (status reports probing). The probe is batched, about 7 perf runs where there were ~25. Switching process re-checks only the new pid. A silent peer can no longer hold --listen. Rounds overlap, and metrics read their files once and pread them.
  • Build and update. --update checks the release's .sha256 sidecar before running the download. Every asset is a musl static build, and both 32-bit ARM assets are soft-float. CI now asserts static linking and the float ABI, runs the protocol tests under ASan+UBSan and TSan, and shellchecks the scripts.
  • Transport (server side). A pre-0.10.0 agent is refused by a server with a pairing code. One malformed frame no longer ends a session. Every agent socket has keepalive and a 30 s send bound. The accept loop survives an accept() error. A server killed mid-capture leaves a listable, replayable session, and a replacement agent waits for the old receiver to finish.
  • Server. The shared source mapper is locked; interleaved addr2line exchanges used to swap names and lines. Tool reads time out, and failures are never cached as ??. Snapshot cost no longer grows with the session: no tree copy, and the worker serializes each changed event once. Ring views are memoized, the parser is 2× faster, and the CORS wildcard is gone. Version stamps carry a generation.
  • UI. An event switch during a fetch is no longer dropped. A reconnecting server no longer reads as a disconnected agent or throws the operator out of replay. Errors reach a banner that can be seen. The source view no longer resets every chunk, and replay no longer queries live-only endpoints. One sampling event by default (A-15). "Stop" stops; "Disconnect" disconnects. The wizard validates its inputs and stops polling when left.
  • Docs, version, hardware. Documentation drift fixed, version 0.12.0 in all seven places, and a hardware pass on two devices.

Verification

Check Result
pytest (built UI present) 442 passed, 1 skipped
pytest (UI moved aside, as CI runs it) 441 passed, 2 skipped
protocol tests 72, plus the parsed-counter assertion
vitest 53 passed (was 24)
Playwright e2e 16 passed (was 10)
ruff, mypy, check_version, OpenAPI and typegen drift clean
agent under ASan+UBSan and TSan clean
five musl cross builds static; armv7 soft-float; armeb soft-float BE8

On hardware, an x86_64 hybrid-CPU container and an 8-core ARM64 board ran the musl agents, installed through install-agent.sh with the checksum verified. Each run used one event at 99 Hz with a 60 s interval for five minutes.

x86_64 container ARM64 board
probe 10.8 s 12.7 s
chunk text sent as 9.8 MB → 0.49 MB 16.8 MB → 0.80 MB (size flush)
stat round per chunk 60.0 s, every chunk after the first same
largest health-frame gap 2.0 s 2.4 s

The hardware run caught a regression the suite could not. Under LC_ALL=C perf prints counters without grouping. The stat parser matched a 12-digit plain count under neither branch, so every counter except page-faults vanished. Every committed fixture was captured under a grouping locale, and the protocol shim printed 1,234,567. The parser now accepts plain digits first, the shim prints C-locale numbers, and the stat coverage test asserts the parsed values.

Self-update on the ARM board refused a tampered asset and verified a good one. The armv7 soft-float agent ran under the board's 64-bit kernel.

Not covered

  • The big-endian ARMv7 target has no ssh. Its checklist is with the maintainer; the armeb asset is checked with readelf -h only.
  • The overnight memory soak, carried since 0.9.0, has still not run.
  • Deferred by design: the findings that need a wire change (A-18, A-25, A-28, A-31).

🤖 Generated with Claude Code

aisubshare and others added 11 commits September 14, 2026 11:36
…ound it

tests/test_parser_compat.py collected two tests with no assert in them: each
counted failures into a local, printed them, and returned a tuple pytest
discards. The PytestReturnNotNoneWarning that would have flagged that was
suppressed in pyproject.toml by a comment naming a file deleted in ac4708e.
Its fifteen cases are the same COMPAT_CASES test_parser.py already checks
with real assertions, parametrized.

test_http_api.py and test_mcp.py each carried a byte-for-byte copy of the
conftest `core` fixture (and test_http_api.py of `client` too), shadowing
the shared ones. The shared fixtures are what both files now use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…what a peer can send

The sixth agent unfreeze; the wire protocol is unchanged. Continuous mode
now flushes at 16 MB of raw text as well as at the interval, where the 64 MB
cap used to swallow every chunk of a long interval without a log line.
perf stat rounds run back to back and each rides the next chunk, instead of
every other interval going uncounted. The call-graph probe records the
probed event and requires call-chain frames, so a PMU-less target and a
perf that never prints a stack are both answered honestly. Every perf
script runs at nice 5, every perf child under LC_ALL=C in its own process
group, and teardown reaps with a three-second grace before SIGKILL, so a
stuck perf no longer holds `stop` and its `sleep` workload dies with it.
Session sockets carry TCP_USER_TIMEOUT and SO_SNDTIMEO; pipes and sockets
are close-on-exec; server->agent frames are capped at 64 KB and the command
queue at 64; ids are validated and escaped, `start` validates its
arguments, and lookups are scoped to `args`. One writev() per frame.

Found on the way: a child forked from the command thread ran the parent's
SIGTERM handler before exec and shut down the shared session socket.
Signals are blocked across fork() and reset in the child.

The server's stat parser accepts every thousands separator perf may print
and no longer raises on a malformed elapsed line. Fifteen new protocol
tests drive a shim that records how perf was run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…obing, sample through rounds

start and reprobe now probe on the collection thread, so ping, status and
stop are answered meanwhile: status reports `probing`, stop cancels the
probe and the pending command is answered `cancelled`, and a lost session
ends it instead of leaving perf running against the target. The probe
itself batches: one perf stat over every candidate (per-event as the
fallback), one perf record over the survivors (bisected on failure), and
one call-graph recording that also serves the -F check -- about seven perf
runs where there were twenty-five. A start on another pid re-checks only
that it can be recorded.

--listen keeps polling its socket while a peer is unauthenticated, so a
silent connection no longer holds the slot; the auth window is 10 s.

Rounds mode forks round N+1's record before symbolizing round N, so the
single-core targets that get rounds mode are no longer blind while perf
script runs; a round waits when the temp filesystem is nearly full.

Metrics: files opened once and re-read with pread(); cpufreq holes are
null instead of ending the list; the thermal zone typed as CPU/SoC is
chosen, or the hottest; pre-3.14 kernels without MemAvailable no longer
count the page cache as used; no 128-core cap. The process list reads
comm from the stat line and cmdline only for what it returns, with
per-core CPU% like the process metrics. Sleeps wake on stop/pause/resume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, sanitizer CI

Self-update fetched the release binary and ran it for --version before any
check, so a rejected update had already executed whatever the origin served.
The .sha256 sidecar is now checked with the device's sha256sum before the
download is made executable; a mismatch is refused and removed, an
unverifiable download over wget (BusyBox's validates no certificate) too.

All five release agents are musl static builds -- the three glibc ones could
not reliably resolve names and armv7 was hard-float, the SIGILL trap the
big-endian pass hit -- from toolchains on the repo's `toolchains` release
(x86_64-linux-musl-cross, aarch64-linux-musl-cross, arm-linux-musleabi-cross
uploaded, sha256 recorded in STATUS). Hardening flags, --gc-sections, and a
stripped asset with the unstripped binary kept as a CI artifact: x86_64
2.06 MB -> 0.67 MB. CI asserts static linking and the soft-float/BE8 ABI,
runs the protocol tests under ASan+UBSan and TSan (which is why g_shutdown
and the child-pid slots are C11 atomics now), runs the C unit tests, and
shellchecks the scripts. build_package.sh names armv7 like every consumer;
install-agent.sh no longer reads a missing `od` as big-endian.

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

Phase 4 of the stabilization pass: the server side of the socket.

Security: a pre-0.10.0 agent (secret in the hello) is refused by a
server with a pairing code configured, with the upgrade named — the item
SECURITY.md scheduled for 0.12.0. A tokenless server still pairs one. The
hello/auth frames are capped at 64 KB, a session frame at 80 MB, one
frame's decompressed text at 256 MB.

Fixed: one malformed frame (non-object metrics, an unreadable stat line,
cpu: null in the summary) ended the session; stat-only chunks after
`start` lost their counters; a device that stopped reading wedged every
later command behind an untimed sendall (keepalive, TCP_USER_TIMEOUT and
a 30 s send bound on both connect paths; relayed command timeouts bounded
1..600 s); one accept() error ended the listener for good; an unwritable
sessions dir killed the receiver before it read a byte; a chunk that
failed to spool was overwritten by the next; a server killed mid-capture
left chunks with no metadata (written at session start now, refreshed
per chunk, finalized by a lifespan shutdown hook; startup sweep removes
empty dirs and recovers orphans); replacing an agent reset state under
the old receiver mid-chunk (it is closed and joined first).

Tests: tests/test_agentlink_session.py (13) drives the receive loop with
a scripted agent; the legacy-token test asserts the refusal.

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

Phase 5 of the stabilization pass: everything server-side of the socket.

Correctness: the source mapper is shared by the rebuild worker and every
request thread with no lock, and its addr2line protocol (write N, read
2N lines) silently swapped names and lines when two threads interleaved
— each pipe now locks across an exchange and the mapper around its
cache-mutating phases. A hung addr2line/readelf froze the worker for
good: reads time out (30 s), a dead tool is restarted and given up after
three failures, and unanswered addresses are neither cached nor
persisted as '??'. The replaced mapper is closed (it leaked children and
a sqlite handle on every PATCH), and closed at shutdown.

Performance: the aggregator no longer deep-copies every event's tree
per chunk; the worker serializes a changed event once (JSON + deflate
segment) and /api/snapshot splices the bytes, gzip included. Ring views
(threads, window, source) are memoized per (generation, chunk_count).
The parser is 2x faster (tab-led lines are frames). Session metadata is
re-read only when its mtime changes.

Protocol additions (all additive): generation, ring_samples and
session_samples on every version stamp; the SSE burst opens with status
and agent. Security: the Access-Control-Allow-Origin wildcard is gone.

Also: hybrid-spelled counters derive IPC and the miss rates (server and
MCP), branch-instructions counts as branches, MCP's default event falls
back to cpu-clock/task-clock; corrupt metadata is a 500 envelope, a
refused delete is reported, the replay cache is built once and renamed
into place, uploads are written off the event loop, --import reports any
failure, ?limit= is bounded, equal ports and malformed map entries are
refused or reported; replay/export no longer count toward the live
symbolization tally; verify_perf replaces the hello instead of mutating it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ay, wizard

Phase 6 of the stabilization pass: the React UI.

Fixed: an event switch during a snapshot fetch was dropped and stamps
were not coalesced; a half-open connection latched the fetch guard (all
requests bounded now); a hybrid CPU's 400 ambiguous_event was swallowed
(the UI picks the concrete PMU event the server names); any SSE drop
read as a disconnected agent, retried every 3 s forever and threw the
operator out of replay (distinct reconnecting state, 3 -> 30 s backoff,
replay left only by the banner's button); dev builds had no SSE (the
StrictMode boot guard swallowed the cleanup); the error banner rendered
inside the hidden profiling view; fourteen swallowed error sites; tables
stuck on "Loading…" against a dead server; exports opening raw JSON on
failure; the source view resetting and scrolling on every chunk with no
fetch cancellation; live-only queries (threads, window) issued during
replay; a process switch resetting the sampling settings; the wizard
advancing past a rejected path, polling index status forever, sending
99999 Hz, never resetting connected, never restoring toolchain/sysroot;
search boxes that could not take a space; six undefined CSS tokens and
a 2.5:1 light-theme contrast.

Changed: one sampling event by default (A-15), every start carries an
explicit list; "Stop" stops and "Disconnect" disconnects; probing state
with elapsed time in the control bar; selectors instead of whole-store
subscriptions; profiling view mounted only while shown; delegated flame
graph handlers; var(--x) sparklines; delete confirmation; ? button;
a11y labels, focus and tab roles; live/recovered session tags.

Tests: 49 vitest (was 24), 16 Playwright scenarios (was 10).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ss, fix docs drift

The hardware pass on both SSH beds found a regression none of the suite
could: the agent runs perf under LC_ALL=C since this release, perf then
prints counters without grouping, and the grouping-tolerant stat parser
matched a 12-digit plain count under neither of its branches. Every
counter except page-faults vanished from the live stat bar. Every
committed fixture was captured under a grouping locale and the protocol
shim printed 1,234,567, so nothing showed it. Plain digits are accepted
first; the shim now prints C-locale numbers and the stat coverage test
asserts the parsed values.

Hardware results (x86_64 hybrid container / ARM64 board): probe 10.8 s /
12.7 s, a 60.0 s stat round on every chunk after the first, chunks of
9.8 MB / 16.8 MB text sent as 0.49 MB / 0.80 MB, largest health-frame gap
2.0 s / 2.4 s (so parsing off the socket thread is not needed),
self-update refuses a tampered asset, armv7 runs under a 64-bit kernel.

Docs: --module-map, cpu-clock/task-clock and the one-event default, the
CI description (mypy, sanitizers, shellcheck, musl toolchains), 12 agent
source files, musl build snippets, live-only thread views in the in-app
docs. CHANGELOG section cut for 0.12.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e screenshots showed

CI's sanitizer jobs failed at link time: `make check` reused the objects
`make SANITIZE=...` had just built and linked them without the sanitizer
runtime. Objects now record the flags they were built with and rebuild when
those change, which also stops a VERSION change leaving the old version
compiled into an agent that rebuilt nothing. CI runs the unit tests
instrumented too.

Reviewing the regenerated docs screenshots found three problems:
- counters sum over the session, and trillions ("11219.4B") and fractional
  task-clock ("1003215.3") ran off the stat cards; values scale to T and
  large fractions scale like integers
- process CPU is per core (100% = one busy core), but the health strip drew
  it on 0-100: a five-core process read 501% in red with a clipped chart;
  severity and chart scale with the core count
- one unnamed frame in 2.4 million raised the "symbolization degraded"
  banner; a profile is degraded only at 1% or more unnamed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s once

The screenshots and GIF are retaken from a live capture of the release
candidate (the header's Disconnect and ? buttons, the control bar's Stop,
Start and Disconnect, the replay banner's exit button, the scaled stat
bar and per-core process CPU) and reviewed frame by frame.

That review also found the flame graph's search readout summing every
matching frame, so a match nested inside another match was counted twice
and the readout said "4 / 266 frames (100.2%)". Each stack now counts once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@harshithsunku
harshithsunku merged commit f81b239 into master Sep 14, 2026
18 checks passed
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.

2 participants