Generated: 2026-07-28
Method: Retroactive backfill of 3 informal passes (code review, critical
sweep, security sweep — commits 37f6a06 through dd90e4e) plus Phase 2
work: Pass 3 concurrency stress testing, Pass 6 adversarial testing, Pass 4
deferred-item cleanup, Pass 12 fuzz testing, and Pass 13 CI hardening, all
against LoopCtl's 59 Go files / ~8,600 lines.
| Severity | Count | Confirmed | Fixed |
|---|---|---|---|
| Critical | 3 | 3 | 3 |
| High | 15 | 15 | 15 |
| Medium | 20 | 20 | 19 |
| Low | 12 | 12 | 12 |
| Total | 50 | 50 | 49 |
The one non-"Fixed" item is P4 (Snapshot's three separately-locked reads), recorded as an accepted design tradeoff rather than a defect — see its entry below for why closing it further isn't worth the cost.
bash demo/test_e2e.sh— 26/26 PASSgo build ./...— cleango vet ./...— cleangolangci-lint run ./...— 0 issuesgo test -race -count=1 ./...— all 9 packages PASS, 159 test functions (up from 126 before Phase 2)go test -fuzz=FuzzClaudeParser -fuzztime=30s ./internal/parser/— 0 crashes across ~650K+ executionsgo test -fuzz=FuzzCodexParser -fuzztime=30s ./internal/parser/— 0 crashes across ~3.8M+ executionsgo test -fuzz=FuzzTailerReadNewLines -fuzztime=30s ./internal/source/— 0 crashes (file-I/O bound, ~7K+ executions)govulncheck ./...— 0 vulnerabilities reachable from LoopCtl's code (2 unreachable stdlib CVEs noted below, not app-code findings)gh issue list --state open— 0 (was 6 at the start of Phase 2)
18 items found across a review pass and a critical-sweep pass, fixed in one commit alongside the initial demo simulator and E2E suite.
- File:
internal/panel/alerts.go - Status: FIXED
- Description: A negative slice index was reachable when the alerts panel's computed width went below the space needed for its content.
- Impact: LoopCtl crashed outright on terminals below a certain width.
- Fix: Added a floor/clamp before slicing.
- File:
internal/metrics/context.go - Status: FIXED
- Description: The two metrics were overwritten per-event instead of accumulated across the session.
- Impact: Displayed cache-hit-rate and token-efficiency numbers were wrong for any multi-event session (the vast majority of real sessions).
- Fix: Changed to running accumulation.
- File:
internal/cli/root.go - Status: FIXED
- Description: The logger was wired to
io.Discardregardless of the--verboseflag. - Impact:
--verbosehad zero effect — a debugging feature that never worked from day one. - Fix: Route to
os.Stderrwhen verbose is set. Now covered byTestSetupLoggerVerboseWritesToStderr(Phase 2).
- File:
internal/metrics/spin.go - Status: FIXED
- Description:
checkStall's result was computed but never assigned into the returnedSpinResult. - Impact: Stalled sessions never surfaced a warning.
- Fix: Wired the result into
IsSpinning/HasWarnings(later refined further by F10 below).
- File:
internal/metrics/session.go - Status: FIXED
- Description: The "top sessions" cost panel used the wrong sort key.
- Impact: Cosmetic — wrong ordering in a UI list, no data loss.
- Fix: Sort by
Costdescending.
- File:
internal/app/model.go - Status: FIXED
- Description:
Kandewere documented in the help bar but did nothing. - Impact: Two of the tool's advertised core actions were non-functional.
- Fix: Implemented
handleKill/handleExport(further hardened in S1-S4 and, in Phase 2, given user feedback — see P2).
- Status: FIXED — infra/tooling only.
- File:
internal/metrics/context.go - Status: FIXED
- Description: Switching models mid-session reset
prevContextLoadto 0, which the compaction-detection logic misread as a real compaction event. - Impact: A cosmetic model switch (e.g. user manually changes CLI tool) showed up as a spurious compaction in the context panel.
- Fix: Compaction detection now ignores the reset caused by a model
switch. Re-verified end-to-end in Phase 2's
TestIntegrationModelSwitchMidStream.
- Status: FIXED — performance/hygiene only, no observable bug.
- Status: FIXED at the time — note that
internal/app/messages.goregressed to an empty (but still present) file afterward; fully deleted in Phase 2 (see P15).
- Files:
internal/panel/cost.go,context.go,alerts.go - Status: FIXED
- Description: Several panels assumed a minimum width/height when slicing/padding content.
- Impact: Crashes on aggressive terminal resize.
- Fix: Added floor clamps throughout.
- File:
internal/metrics/spin.go - Status: FIXED
- Description: A zero window size (reachable via a misconfigured or future config path) divided by zero in a percentage calculation.
- Impact: Guaranteed panic.
- Fix:
NewSpinDetectornow defaultsWindowSizeto 50 when<= 0.
- Status: FIXED — guarded against a negative fill percent producing an invalid bar width.
- File:
internal/source/collector.go - Status: FIXED
- Description: A tail-read error caused the whole batch of lines from that cycle to be dropped instead of just skipping the failed read.
- Impact: Real event data loss under transient read errors.
- Fix: Errors are now logged and the loop continues; re-verified by
Phase 2's
TestAdversarialBinaryGarbageMixedWithValidLinesandTestIntegrationMalformedJSONLMixedWithValid, which specifically prove a bad line doesn't take good lines around it down with it.
- File:
internal/source/collector.go - Status: FIXED
- Description:
runDiscoverynever pruned tailer/parser map entries for sessions that disappeared. - Impact: Slow memory leak for any long-running loopctl process monitoring a machine with session churn.
- Fix: Added pruning against the freshly-discovered ID set every cycle.
Covered by
TestRunDiscoveryPrunesStaleLTFTailersand, for the session-disappearing case specifically, Phase 2'sTestAdversarialSessionFileDeletedWhileTailing.
C6: Alert panel width clamping (Medium) / C7: handleExport error handling (Medium) / C8: missing updatePanels on resize (Medium)
- Status: FIXED — see commit
37f6a06body for detail; superseded by the Phase 1 rewrite ofinternal/app/model.goand further hardened for user feedback in Phase 2 (P2).
- File:
internal/source/collector.go - Status: FIXED
- Description:
Start/Closeread and wrotec.cancelwithout synchronization. - Impact: A genuine data race, catchable by
-race, between starting/stopping the collector. - Fix: Added
cancelMu. Phase 2 went further and added async.WaitGroupsoCloseblocks until the background loop has fully exited — see P7.
- Status: FIXED — guarded with an early return for
n <= 0.
- Status: FIXED — defaults to Sonnet-tier pricing when the model string is empty.
- File:
internal/parser/codex.go - Status: FIXED
- Description: Codex parsing lacked the two-generation dedup set the Claude parser already had, allowing re-processed/duplicate log lines to double-count tokens.
- Impact: Inflated cost/token figures for Codex sessions.
- Fix: Added the same
currentGen/previousGenrotation asClaudeParser. Now fuzz-tested (FuzzCodexParserassertscurrentCountnever exceedsmaxSeenRequests).
- File:
internal/parser/claude.go - Status: FIXED
- Description: A malformed timestamp parsed to the zero
time.Time, which downstream stall-detection logic read as "an eternity since last activity." - Impact: False stall alerts triggered by a single malformed log line.
- Fix: Falls back to
time.Now()on parse failure. Fuzz-tested: every event returned byFuzzClaudeParseris asserted to have a non-zero timestamp.
- File:
internal/parser/claude.go - Status: FIXED
- Description: If the first content block in a multi-block assistant message failed to unmarshal, the usage tokens meant for it silently attached to a later block instead.
- Impact: Misattributed cost/token data — the number is right in aggregate but assigned to the wrong tool call, corrupting any per-tool-call analysis.
- Fix: Added a
tokensAssignedguard so tokens attach exactly once, to the first successfully-parsed block.
- Status: FIXED at the time (added a case, but with no user-visible effect); Phase 2 closed the loop fully — see P2.
- File:
internal/metrics/context.go - Status: FIXED
- Description: Fill percentage was computed from
input_tokensalone (typically ~3 for a cached request) instead of the full context load (input + cache_read + cache_write). - Impact: The context-health panel — one of LoopCtl's headline features — showed 0% for essentially every real session.
- Fix: Sum all three token categories for the fill-percent denominator. Verified against real Claude Code session data at the time; re-verified in Phase 2's deterministic integration suite.
- File:
internal/metrics/spin.go - Status: FIXED
- Description: The heuristic counted matching tool-call fingerprints anywhere in the 50-entry circular buffer, regardless of how far apart in time they occurred.
- Impact: Running the same command 3 times over a 3-hour productive session tripped a false SPIN flag. Verified against 36 real local Claude Code sessions: 12/36 were falsely flagged before the fix, 1/36 after (the one remaining flag was the intentional demo spin scenario). This is arguably the single most user-visible bug in the project's history — rated High rather than Medium specifically because it actively broke the product's core value proposition for real users, even though it never crashed or lost data.
- Fix: Added a 5-minute window to
checkRepeatedTools.
- Status: FIXED — stall now sets
HasWarnings(severity "warning"), notIsSpinning(severity "critical"), matching that a stall is a normal research phase, not necessarily a problem.
- File:
internal/app/model.go - Status: FIXED —
0644→0600for files,0755→0700for the exports directory. Session exports can contain tool inputs/outputs, file paths, and error messages — not something to leave world-readable.
- Status: FIXED —
filepath.Base(sessionID)applied before joining into the export path.
- File:
internal/app/model.go - Status: FIXED — rejects PID ≤ 1 and verifies the process still
exists (
Signal(0)) before sendingSIGTERM. Phase 2 (P2) built on this by surfacing the outcome to the user instead of failing silently.
- Files:
internal/source/claude.go,internal/source/tailer.go - Status: FIXED —
os.Lstat+ModeSymlinkcheck before opening any discovered session file, preventing a maliciously or accidentally symlinked "session file" from causing LoopCtl to read an arbitrary path. Dedicated regression test added in Phase 2:TestTailerSkipsSymlinks.
- Status: FIXED — capped at 10,000 entries per session to prevent memory exhaustion from a pathological session touching an enormous number of distinct files.
- Status: FIXED —
cacheRead/(input+cacheRead)instead ofcacheRead/input, which had been producing inflated (sometimes >100%) values.
- File:
internal/source/tailer.go - Status: FIXED — offset is now updated even on a non-EOF read error, preventing the same lines from being re-read and double-counted on the next cycle.
- Status: FIXED — logged at Debug level instead of discarded.
Three latent issues were filed as GitHub issues (#1, #2, #3) after the security-hardening pass above but before formal stress testing. This pass closes all three with either a fix or a documented "safe by design" conclusion — never left open once investigated, per the audit protocol's principle that a disproven issue left open is its own kind of false confidence.
- File:
internal/source/collector.go - Status: CONFIRMED — PARTIALLY FIXED / accepted design tradeoff
- Description:
Snapshot()buildsSessionsfrom oneSessionStore.Snapshot()call,Alertsfrom a separateCollector.mu-guarded read, and (previously)DailyTotalfrom a third, independentSessionStore.DailyTotal()call. This is not a data race — each critical section is correctly synchronized on its own — but a write landing between sections could theoretically produce aDataMsgwhose three fields never coexisted as one consistent state. - Impact: In practice, bounded to a one-tick (1s default refresh) staleness window on values that are already display-only. Not observable as incorrect by a user watching the dashboard.
- Fix:
DailyTotalis now computed by the newmetrics.DailyTotalFromSnapshot(snap)from the samesnapused to buildSessions, eliminating that specific inconsistency (verified byTestDailyTotalFromSnapshotMatchesConcurrentReadsunder concurrent writers).Alertsremains from a separately-timed read by design — it's computed on its own 2-second tick cadence insidebuildAlerts, distinct from the TUI's render cadence, and merging them would mean recomputing alerts on every render tick for no real benefit. Accepted as the intentional tradeoff for a 1Hz-refreshed dashboard rather than pursued further.
P5: Lock ordering — buildAlerts holds Collector.mu then acquires SessionStore.mu (Medium) — GitHub #2
- Status: SAFE BY DESIGN — no live deadlock today, confirmed by
grepping every lock/unlock site in both
CollectorandSessionStore: the only place both locks are held simultaneously isbuildAlerts, always in the orderCollector.mu→SessionStore.mu. Nothing acquires them in the reverse order. Documented explicitly with comments on bothCollector.muandSessionStore.muso a future contributor doesn't invert the order. Stress-tested under-racewith concurrentbuildAlerts/ProcessEvent/Snapshotcalls for 2 seconds with a 10-second deadlock timeout inTestCollectorSnapshotAndBuildAlertsConcurrent— completes cleanly.
- File:
internal/metrics/session.go - Status: CONFIRMED — FIXED
- Description:
SessionStore.Snapshot()'s per-session copy (cp := *s) copied theSpinResultstruct by value, butReasons []string's backing array was shared with the live session. Investigation confirmedSpinDetector.Check()always allocates a freshReasonsslice per call and never mutates a previous call's backing array — so this was "safe by accident of the current implementation," not safe by contract. A future optimization reusing a pooled buffer (plausible, sinceSpinDetectoralready uses fixed-size ring buffers elsewhere) would silently reintroduce a real,-race-detectable bug. - Fix: Added a defensive copy (
cp.Spin.Reasons = append([]string(nil), s.Spin.Reasons...)), mirroring the existingFilesChanged/ErrorMessagesdeep-copy pattern in the same function. Turns "verify it stays fine" into a structural guarantee. Regression tested inTestSessionStoreConcurrentStress, which mutates a returned snapshot'sReasonsslice from a background goroutine and asserts the mutation never leaks into a laterSnapshot()call.
- File:
internal/source/collector.go - Status: CONFIRMED (by code reading) — FIXED
- Description:
Close()calledcancel()and returned immediately, without waiting for the backgroundloop()goroutine to actually observectx.Done()and exit. AStart()called immediately afterward runsrunDiscovery()/processAllTails()synchronously on the caller's goroutine — if the previousloop()goroutine hadn't exited yet and was itself mid-runDiscovery()/processAllTails()(only possible ifClosehappens to land while a 30s/2s ticker has just fired), both goroutines would mutate the unsynchronizedc.tailers/c.parsersmaps concurrently. - Impact: Given the collector's actual ticker intervals (30s
discovery, 2s tail), the window is narrow — the background goroutine is
parked in a
selectfor close to the entire interval between ticks — but "narrow" isn't "impossible," and a concurrent map write in Go can crash the whole process (fatal error: concurrent map writes), not merely trigger a-racewarning.TestCollectorRestartCycleRacewas run against the pre-fix code 5× and did not happen to reproduce the exact interleaving within its short deadlines, which is reported honestly here rather than overstated — the fix itself doesn't depend on having reproduced the race, since blockingClose()on the loop's exit is standard, unambiguously-correct practice for any type managing a background goroutine. - Fix: Added a
sync.WaitGroup—Start()callswg.Add(1)before spawningloop(),loop()callsdefer wg.Done(), andClose()now callswg.Wait()aftercancel().Close()is safe to call fromdeferincli/root.goas before (the loop's ctx-check-driven exit is effectively immediate, soWait()adds negligible latency to shutdown).
Three items were confirmed and filed but deliberately deferred during the CVE/data-integrity pass, tagged for OSS contribution. All three are now fixed rather than left open, per the audit protocol's "no indefinitely deferred confirmed items" principle.
- File:
internal/metrics/session.go - Status: FIXED
- Description:
time.Now().Truncate(24 * time.Hour)rounds down to the nearest 24-hour boundary from the Unix epoch in UTC — not local midnight, despite reading as "today" to anyone in a non-UTC timezone. - Impact: For any user west of UTC, "today's total" flips over at a time that doesn't match their actual midnight (e.g. 4-8pm local time, depending on timezone), silently including or excluding sessions a user would reasonably expect on the other side of the line.
- Fix: Compute local midnight explicitly via
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()). Extracted into a shareddailyCutoff()helper used by bothDailyTotaland the newDailyTotalFromSnapshot(P4) so they can never disagree on the day boundary.
- File:
internal/app/model.go - Status: FIXED
- Description: Every path through
handleKill— no session selected, session inactive, no PID, process already gone, signal failure, and success — returnednil, so the TUI never displayed anything different no matter what happened. The same was true ofExportDoneMsg, which was already being returned but discarded unread inUpdate(). - Impact: Pressing
Korewas a black box — a user watching the dashboard had no way to tell whether a kill signal was actually sent, or whether an export actually wrote a file, without checking externally (ps, the filesystem). - Fix: Added a
model.KillDoneMsg{ProjectName, Err}message type (mirroring the existingExportDoneMsg), aModel.statusMsg/statusExpirypair that renders in the footer for 4 seconds, and explicit early-feedback messages for the "no session selected" / "not active" / "no PID" cases that previously failed silently.
- Files:
internal/panel/sessions.go,internal/panel/history.go - Status: FIXED
- Description: Both tables used fixed per-column widths that summed to a fixed total (~84 and ~78 columns respectively) regardless of the actual terminal width, so any narrower terminal simply overflowed.
- Impact: On any terminal narrower than the fixed sum (a common laptop split-pane width), the dashboard rendered wider than the visible area.
- Fix: New
internal/panel/columns.gowith afitColumnshelper that shrinks lower-priority columns toward a documented per-column minimum before exceeding the terminal width, instead of ignoring it. Practical floor dropped from a hard ~84/~78 to ~61/~56 columns. Covered byinternal/panel/columns_test.goacross a range of widths including the regression case (83 columns, just under the old hard requirement).
Built as internal/source/adversarial_test.go, 7 automated scenarios
(A1-A5, A10-A11) against the live pipeline — a gap noted at the start of
Phase 2, since prior "adversarial testing" (cited in dd90e4e's commit
body) was manual and left no committed, repeatable test code.
| # | Scenario | Result |
|---|---|---|
| A1 | Binary garbage mixed with valid JSONL lines | PASS — parser errors on the garbage line only; surrounding valid lines still parse |
| A2 | Empty (0-byte) session file | PASS — baseline, no lines, no error |
| A3 | Single line far exceeding 1MB (mid-write, then terminated) | PASS — oversized content never reaches the parser; recovery after the next newline works and doesn't re-accumulate on repeated reads |
| A4 | Partial/truncated write at EOF (process killed mid-write) | PASS — buffered and correctly reassembled once the rest of the line arrives |
| A5 | 500 synthetic sessions, one tail cycle | PASS — completes in ~0.2-0.8s, well under the 2s tail-tick interval |
| A10 | Session file deleted while being tailed | PASS — ReadNewLines returns an error, not a panic; a fresh Discover() call no longer reports the deleted session |
| A11 | Session path replaced by a directory (and reversed) | PASS — no panic on either direction; Lstat's symlink check doesn't misclassify a directory |
A6 (rapid start/stop) is covered separately in collector_race_test.go —
see P7. A7-A9 (config validation for negative/extreme values) were already
fixed as part of Phase 1's config.Validate() rewrite — confirmed via
TestValidateNegativeBudget and TestValidateNegativeSpinFields, not
re-implemented here. A8 (per-session WindowSize configurability) is
N/A — SpinConfig.WindowSize is intentionally not exposed via YAML;
confirmed by grep, not a gap.
A12 (two simultaneous loopctl instances) is a documented design
decision, not an automated test: LoopCtl has no daemon, no lock file, and
no shared mutable state between two OS processes — each has its own
independent in-memory Collector/SessionStore, and both only ever read
(never write) session log files. The one shared-mutable-state path is the
export file (~/.config/loopctl/exports/<sessionID>.json); os.WriteFile
opens with O_TRUNC, making a single-syscall write safe against a torn
file even if two instances export the same session at the same instant.
This is accepted as correct-by-design rather than built out as a
dual-process E2E harness, which would add real CI cost for a scenario with
no actual failure mode to catch.
- File:
Makefile - Status: FIXED
- Description: The pre-existing
fuzztarget used-fuzz=Fuzz, a prefix pattern. It silently did nothing before Phase 2 (zerofunc Fuzzfunctions existed in the repo despite the target's existence). OnceFuzzClaudeParserandFuzzCodexParserwere added to the same package in this phase, the same pattern started failing outright:go testrefuses to fuzz when a pattern matches more than oneFuzzfunction. - Fix: Target now runs each of the three fuzz functions individually, 30s each.
- Status: FIXED —
FuzzClaudeParser,FuzzCodexParser(both ininternal/parser/fuzz_test.go),FuzzTailerReadNewLines(internal/source/fuzz_test.go). 0 crashes across ~4.5M+ combined executions in local 15-30s runs; no crash corpus was produced (Go only persists failing inputs totestdata/fuzz, and none were found).
- Status: FIXED —
internal/source/integration_test.go, 8 scenarios driving the full Discoverer → Tailer → Parser → SessionStore → Collector pipeline against synthetic fixtures int.TempDir(). Complements (does not replace)live_test.go, which is a valuable but CI-non-portable sanity check that skips cleanly when no real session data exists. Required two small, additive production constructors:NewClaudeDiscovererAt/NewCodexDiscovererAt, which take an explicit base directory instead of always resolving$HOME— also generally useful for a future--claude-dir-style flag.
- Status: FIXED —
internal/cli/root_test.goadded:--version,--help, unknown flag, unknown subcommand, and full table-driven coverage ofsetupLogger's level-mapping logic (previously untested —--verbosehad already been broken once before, per R3 above, without any test that would have caught it).
- File:
.github/workflows/ci.yaml - Status: FIXED — added a
vulncheckjob, gating the release job alongsidebuild/lint. Local run before wiring it in: 0 reachable vulnerabilities in LoopCtl's own code or call graph. Two vulnerabilities were reported in the Go 1.26.4 standard library itself (GO-2026-4970,GO-2026-5856, both fixed in stdlib 1.26.5) — neither is reachable from any code LoopCtl calls (confirmed via govulncheck's symbol-level analysis, not just package-level), so these are N/A findings, not app-code defects. Recommend bumping the CI Go version to 1.26.5+ at the next convenient point as routine toolchain hygiene, not as a security fix for LoopCtl itself.
- File:
internal/source/claude.go - Status: FIXED — logs at Debug level if
scanner.Err()is non-nil after the scan loop (e.g. a line exceeding the 64KB metadata-scan buffer). Low severity — the function already falls back to safe defaults (info.ModTime(),DecodeProjectDir) — but previously left zero trail for diagnosing why a session's metadata came back empty.
- Status: FIXED — removed
style.Header,style.Highlight(both genuinely unreferenced; golangci-lint'sunusedlinter doesn't flag unused exported identifiers by default, so these survived every prior lint pass undetected), the emptyinternal/app/messages.gofile, and the write-onlySession.LastEventfield (set by both discoverers, read by nothing).
- Status: FIXED — folded into Pass 8 above.
- Pass 7 (Live Enforcement): N/A — LoopCtl is a read-only dashboard by
design (per its own README positioning against LoopGuard, the
enforcement daemon in the same ecosystem). Its only user-initiated
process action is the manual
Kkill keybinding; there is no automated enforcement loop to test. - Pass 9 (Last Items): Closed — no further items surfaced once Passes
1-8, 11-13 above were completed;
gh issue list --state openreturns 0.