Skip to content

arch-audit v0.6.x — checkpoint: Batches F→J landed#136

Merged
utof merged 50 commits into
mainfrom
architecture-audit/v0.6.x
Apr 24, 2026
Merged

arch-audit v0.6.x — checkpoint: Batches F→J landed#136
utof merged 50 commits into
mainfrom
architecture-audit/v0.6.x

Conversation

@utof

@utof utof commented Apr 24, 2026

Copy link
Copy Markdown
Owner

Summary

Architecture-audit branch checkpoint #2 — continues from #130 (Batches A→E). Branch stays open; test-architecture work (#101 + #108) lands on a follow-up branch.

Bundles five batches plus #131 hardening:

  • Batch F — FTS5 trigger codegen. crates/db/src/schema/ (Rust + minijinja templates) owns FTS5 trigger DDL; refinery owns table DDL. install_fts_triggers runs at every writer start (sub-ms idempotent). Snapshot-tested + 32-case proptest. Closes research: should perima drop SQLite migrations entirely (post-v1 reopen) #134.
  • Batch Gcrates/db/src/search_repo.rs test extraction. Moves 22 raw-SQL helpers + 5 test files to crates/db/tests/{common,search_semantics,search_triggers,search_proptests}.rs. Production search_repo.rs collapses to 117 LOC.
  • Batch H — frontend state. Three-layer split: TanStack Query v5 (server) + Zustand 5 (UI) + useState (component-local). TanStack Router with createHashHistory(). apps/desktop/src/App.tsx 322 → 42 LOC. `Register { defaultError: CoreError }` makes typed errors flow without per-call generics. `useDomainEvents` is the sole `subscribeToAppEvents` consumer. `WatcherBanner` deleted (errors now toast).
  • Batch I — observability. `perima_app::telemetry::init_subscriber` (single home, called once per process). `tracing-appender` rolling-file (HOURLY rotation under `~/.local/state/perima/logs/`); JSON in release, pretty in debug. 6 `#[tracing::instrument]` sites. `perima debug-report` subcommand bundles last K log files + `GIT_SHA` from `build.rs`. Closes Structured logging: tracing spans + JSON output + perima --debug-report #100.
  • Batch J — `fast_image_resize::Resizer` amortization + extractor error visibility. `Resizer` lives once per worker task (was per-call). `nom-exif` `MediaSource` open errors at WARN (real I/O bug); parse errors at DEBUG (normal — file has no EXIF). Closes Error granularity: MediaSource + MediaParser errors silently swallowed in extractor #110, perf(media): amortize fast_image_resize Resizer scratch buffers across thumbnail-worker iterations #111.

#131 hardening (bundled inline): `clippy.toml` bans `rusqlite::Connection::open` outside writer code; `WriteCmd::Shutdown` variant for clean writer-actor termination.

Test plan

  • `just ci` green locally (cargo-deny, typos, cargo-doctest, cargo-nextest --workspace, clippy `--workspace --all-targets -D warnings`)
  • 274/274 nextest workspace; 81/81 vitest (apps/desktop)
  • CI green (Linux/macOS/Windows matrix + bindings-drift + kani)

Closes

Filed during this push

Merge plan

Squash-merge. Branch is not deleted — work continues on `architecture-audit/v0.6.x`.

utof added 30 commits April 23, 2026 13:38
Two structural guards layered on top of the rusqlite 0.39 / SQLite
3.51.3 upstream fix for the second-Connection bug class (GH #131,
closed alongside).

clippy.toml (new): disallowed-methods on rusqlite::Connection::open.
RW second Connections must go through SqliteWriter; RO inspection
uses Connection::open_with_flags(SQLITE_OPEN_READ_ONLY |
SQLITE_OPEN_NO_MUTEX). Header is honest about scope: RO opens are
defense-in-depth + intent signal, NOT immunity from the upstream
lock-order-inversion close race (that comes from the rusqlite
version pin alone). The open_with_flags(RW|CREATE) loophole is
acknowledged; legit RW exceptions are #[allow]-annotated with a
WHY-comment.

deny.toml: [bans] for rusqlite + libsqlite3-sys with wrappers
scoped to perima-db (prod) + perima/perima-app (dev-deps) +
r2d2_sqlite/refinery-core (transitive linkers required by
cargo-deny's wrappers semantics). Adding rusqlite to a 4th
first-party crate is now a hard CI failure.

Call-site adjustments:
- crates/cli/tests/{manifest_created,scan_persists,
  scan_with_metadata_test,scan_with_volumes}.rs: 6 read-only sites
  converted to open_with_flags(RO|NO_MUTEX); 1 UPDATE-injection site
  in scan_with_volumes::sentinel_rows_migrated keeps RW with #[allow]
  (the perima scan subprocess writer has already exited by the time
  the test injects, so no concurrent second writable Connection).
- crates/db/src/manifest.rs: production write_manifest #[allow]
  (separate manifest.db file on the user's volume, not the main
  perima.db; different unixInodeInfo => no inversion risk); 3
  unit-test sites RO-converted.
- crates/db/src/search_repo.rs: seed_conn proptest helper #[allow]
  (#124 backlog; post-3.51.2 safe).
- crates/db/src/connection.rs: open_and_migrate #[allow] (this IS
  the SqliteWriter's single Connection entry point; the lint exists
  to push callers INTO this function).
- crates/app/src/search.rs: seed_via_conn test FTS seed #[allow]
  (post-3.51.2 safe but fragile pattern; structural fix would route
  via writer).

Verified: just clippy clean, cargo deny check bans ok, full nextest
green (240/240 + 12/12 desktop).
…gic-drop teardown

Pre-fix shutdown depended on every cloned Sender<WriteCmd> (held by
repos / handlers) being dropped before SqliteWriterHandle::join().
Forgetting one parked the writer thread in flume::Receiver::recv
forever and hung pthread_join. The bug class produced "magic-drop"
callsites that listed N explicit drops matching how many senders
the surrounding code had cloned (e.g. GH #131's 3-of-3 fix in
run_scan_inner_with_metadata; the magic number went 1 -> 4 -> 3
across two commits in two days).

This change replaces the implicit "drop all senders" contract with
an explicit Shutdown signal:

- crates/db/src/cmd.rs: add WriteCmd::Shutdown variant (no payload).
  WriteCmd is #[non_exhaustive] so this is non-breaking.
- crates/db/src/writer/mod.rs:
  * run_writer_loop matches Shutdown and returns before dispatch.
  * dispatch gets unreachable!() for Shutdown to keep the existing
    no-_-arm exhaustiveness pressure on future variants.
  * SqliteWriterHandle::join sends Shutdown via try_send before
    joining the thread. Surviving sender clones become inert
    (next try_send -> Disconnected) instead of blocking shutdown.
  * NO Drop impl on the handle. Adding Drop would Shutdown-trigger
    on every handle scope-exit, breaking the CLI's deliberate
    "drop handle, let sender clones in repos extend the writer's
    life" pattern (crates/cli/src/main.rs::build_container). Tested
    and confirmed during development: Drop broke 6 CLI integration
    tests with "sending on a closed channel".
  * Regression test writer_shuts_down_with_outstanding_sender_clones
    spawns the writer, clones a sender, calls handle.join() WITHOUT
    dropping the clone, and asserts the join completes in <5s + the
    surviving clone's try_send returns Err(Disconnected).

- crates/desktop/src/commands.rs::run_scan_inner_with_metadata: 5
  lines (drop(file_repo); drop(vol_repo); drop(sentinel_repo);
  writer.join(); + WHY-comment) collapse to 1 + a one-paragraph
  WHY-comment pointing at the new pattern.

The ~15 other "drop(repo); writer.join();" sites in test fixtures
across crates/db/tests/ and crates/desktop/tests/ keep their
explicit drops; they are now no-ops but harmless. A future cleanup
sweep can remove them.

Verified: just clippy clean, full nextest green (240/240 + 12/12
desktop). The Shutdown regression test passes in 93ms.
WHY: Batch F replaces 16 hand-written FTS5 triggers with one
template + Rust spec. minijinja 2.x is the audit-§4.6 pick (small
runtime, jinja-familiar, supports {% macro %} blocks for shared
aggregation patterns). default-features=false + explicit
[macros, loader] features keeps the dep slim.
WHY: Batch F's single source of truth for the 16 FTS5 sync triggers.
Spec entries replace the hand-copied trigger SQL across V006/V007/V008.
LEGACY_TRIGGER_NAMES preserves V007-only names so existing dev DBs
converge after the boot-time install lands. Template + render in next
two tasks consume FTS_AGGREGATIONS.
Closes 8 clippy::doc_markdown errors flagged by Task 2 code-quality
review. SQL table/column names (search_content, search_index,
deleted_at) and one helper-fn name (representative_path) plus the
proper noun SQLite were used bare inside doc comments. Workspace
clippy lint level is -D warnings, so these were CI-gate-blocking.
No semantic change.
WHY: Single template + 4 shared aggregation macros encode the V007->V008
lesson (every tag/metadata aggregation filters deleted_at on BOTH the
link and entity tables) once. Per-BodyKind macros expand to the V008
trigger bodies. Render fn lands in next task; this commit just
introduces the template asset + a parse smoke test.
WHY: render_fts_triggers() reads spec.rs + template, returns the full
install SQL (LEGACY DROPs + current DROPs + 16 CREATE TRIGGER bodies).
install_fts_triggers(&conn) execute_batch'es it. Per-source-table
snapshots pin the rendered SQL byte-for-byte; reviewer-verified to
match V008-final form semantics. Wired into writer in next task.

Snapshots land at crates/db/src/schema/snapshots/ (insta default for
unit tests in src/, NOT the plan-misstated tests/snapshots/ path).

Adds insta dev-dep to perima-db (workspace pin already present).
WHY: Closes the V006->V007->V008 trigger drift bug class. SqliteWriter::start
and ::start_in_memory both call install_fts_triggers after their refinery
migration apply. Idempotent — runs every boot, DROPs V006/V007/V008
trigger names + LEGACY V007-only names, then CREATEs codegen-rendered
bodies. start_in_memory_installs_fts_triggers smoke test verifies the
in-memory path; the file-backed path is exercised by every existing
FTS-touching integration test.
Code-quality reviewer minors on commit fb2c422: add WHY comments at
both install_fts_triggers call sites (sibling sites in the file all
carry inline WHY blocks for boundary decisions, per CLAUDE.md), and
swap drop(h) → h.join() in the new smoke test so it matches sibling
writer tests' shutdown discipline (SqliteWriterHandle has no Drop
impl, so drop() leaks the writer thread until process teardown).
WHY: pins the boot-time install contract. Second call on the same conn
is a verified no-op on sqlite_master rows. Catches any future template
edit that accidentally introduces non-deterministic SQL output.
WHY: pins the LEGACY_TRIGGER_NAMES contract. Existing dev DBs that ever
ran on V007-only state carry 'search_after_location_hash_change'; this
test pre-seeds it and asserts install_fts_triggers DROPs it. Removing
or shrinking LEGACY_TRIGGER_NAMES without a paired add to FTS_AGGREGATIONS
fails this test.
Code-quality reviewer minor on commit 56c1e16: the test was hard-coded
to one legacy name ('search_after_location_hash_change') but its plural
name promised coverage of every entry. Switch to a loop over
LEGACY_TRIGGER_NAMES so adding a 2nd entry (e.g. when V010+ retires
another V008-era trigger) automatically gets covered. Adds a
trigger_count helper to dedupe the two query_row sites and a WHY
comment explaining why the pre-seed body is a minimal stub.
WHY: closes V008 #2 (hash-change) + V008 #3b (restore) coverage gaps.
Existing fts_consistent_under_tag_churn covers tag focus; this one
exercises file_locations ops (insert/update_hash/update_path/soft_delete/
restore) with tag attach/detach interleaved. 32 cases per GH #124.
Code-quality reviewer minors on commit 18cc96c: replace the magic 3
across the strategy / Model / seed loop with a single SLOTS const
(commented to flag what bumping it requires); add a debug_assert in
shadow_hash that enforces the slot < 128 single-byte encoding bound;
extend attach_tag_raw's WHY comment to spell out the idempotency
contract that mirrors the model's BTreeSet::insert.
WHY: Batch G inventory pass landed all ~22 raw-SQL test helpers + cross-
cluster consts in a single shared module per spec §5.2. Tests still live
in src/search_repo.rs this commit; subsequent commits move tests cluster-
by-cluster + delete the in-source mod tests block. The #![allow(dead_code)]
is load-bearing — helpers are consumed by 3 sibling integration-test
binaries each compiling common/mod.rs with only a subset used per binary
(workaround per rust-lang/rust#46379).
WHY: Batch G cluster 1 of 3. Tests that exercise search behavior without
trigger-side assertions land in search_semantics.rs. Helpers consumed
from common/mod.rs (Task 2). Test count unchanged (118 in-crate tests;
11 tests now in separate search_semantics integration-test binary).
Also fixes common/mod.rs: adds #![allow(unreachable_pub)] to suppress
clippy -D warnings for pub helpers in test-binary-local mod.
…_post_v007

WHY: spec-review nit. Doc comment "I5: calling SearchRepository::rebuild()
twice..." was dropped during the verbatim move in 2e65ae0. Single-line fix
preserves the original byte-identical-move invariant.
WHY: Batch G cluster 2 of 3. T22/T40-T48 + trigger_sync_on_* +
multi-location-rename + soft-delete/restore + tag-rename-propagates land
in search_triggers.rs. Helpers consumed from common/mod.rs.

Also folds in 3 style NITs from Task 3 code review:
- //! before #![allow] in search_semantics.rs (matches dominant convention).
- Inline WHY on #![allow] attrs in common/mod.rs (matches Task 2 style).
- No blank line between use common and use perima_core (single import group).

Adds #[allow(dead_code, unused_imports)] to src/search_repo.rs::tests to
silence -D warnings on helpers now only used by the 2 remaining proptests;
Task 5 deletes the mod wholesale.

Test count unchanged (118).
WHY: Batch G cluster 3 of 3 (final). Both proptests
(fts_consistent_under_tag_churn + fts_matches_ground_truth_under_soft_delete_churn)
move to search_proptests.rs with case counts unchanged (#124 cap preserved).
Now-empty #[cfg(test)] mod tests block deleted from src/search_repo.rs.
Stale comments at fts_codegen_round_trip.rs:~57,~205 updated to point at
common::seed_conn / common::test_db. Long dead_code WHY in common/mod.rs
wrapped to ≤100-char lines (Task 4 review NIT).

Production search_repo.rs settles at ~120 LOC; audit §A9's literal 4-way
split (mod.rs/filters.rs/query.rs/rows.rs) declared SUPERSEDED by Batch C —
no meaningful production split surface remains. Test count unchanged (118).
WHY: code-review NIT. Multi-line WHY between two #![allow] attrs read
ambiguously. Shortened comment fits inline (92 chars) so attribute and
its WHY are visually adjacent like the unwrap_used allow on line 1.
…ingleton

WHY: Batch H Task 1. Three new prod deps (@tanstack/react-query@^5,
zustand@^5, @tanstack/react-router@^1) + queryClient singleton with
desktop-calibrated defaults (5min staleTime, no refetchOnWindowFocus).
Register module augmentation makes useQuery error type = CoreError
without per-call generics (which would break T inference per v5 TS docs).
QueryClientProvider wraps App; Router added in Task 6. No behavior
change yet.
Reviewer nit: gcTime: 30min was the only default option missing a WHY
block. The other four defaults all explain their non-default choice;
gcTime should too — it diverges from v5's 5min default deliberately
(native desktop, ample RAM, route-switch cache warmth).
…lumes/search

WHY: Batch H Task 2. Factory pattern per spec §5.4. Each domain exports
xxxKeys namespace + xxxQueryOptions(args) factory + use<Domain>(args)
hook. Bridge to neverthrow::ResultAsync via .match(ok, err => throw err).
Search query gates on `enabled: query.length >= MIN_QUERY_LEN` (= 2);
empty input fires no IPC. eslint-disable on throw sites (with WHY block)
because CoreError is the Register-augmented defaultError type — wrapping
in Error would lose the typed discriminant. No consumers yet; tests untouched.
WHY: Batch H Task 3. Single store, slice pattern per Zustand 5 docs.
Slices: View (viewMode + selectedTagId), Search (raw + debounced query),
Scan (status + lastReport), Notifications (toast queue).
notifyError(err: CoreError) is convenience for error paths.
No persist middleware — intentional per-restart reset matches today.
No Provider needed (Zustand singleton). No consumers yet.
WHY: Batch H Task 4. Single subscription site for app-event channel.
Per-reason surgical invalidation (TagsChanged → tagsKeys; FilesChanged
+ MetadataChanged → filesKeys debounced 300ms; SearchIndexRebuilt →
searchKeys). Upgrades Batch E's TODO Batch H coarse-refetch placeholder.
TS-exhaustive switch on event.kind AND event.data.reason — adding a new
variant becomes a compile error. No consumer yet.
WHY: Batch H Task 5. Renders useUiStore.notifications. info kind
auto-dismisses after 5s; error persists until user clicks ×.
Replaces ad-hoc error + watcherError state in App.tsx (deferred
from Batch D D-11). Not yet mounted; mounts in Task 7.
…der IndexRoute

WHY: Batch H Task 6. Code-based routes (NOT file-based) — single
route in v0.6.x. createHashHistory matches Tauri static-served
dist/index.html. App.tsx becomes the root-route component (mounts
inside <RouterProvider> via the rootRoute.component). IndexRoute is
a placeholder — Task 7 fills with the file/tag/search composition.

KNOWN: App.test/App.compose/ScanButton tests fail this commit — they
assert against pre-router App content. Task 10 rewrites them (see
spec §4.11 for the per-file rewrite scope).

NOTE: All 78 tests pass this commit (no actual failures); the KNOWN
note is pre-documented for Task 7 which will cause the failures.
WHY: Batch H Task 7. App.tsx shrinks from 322 LOC to 42 LOC: provider-
free root-route component (useDomainEvents mount + layout chrome). 12
useStates + 2 useEffects in App.tsx → 0. ViewModeToggle extracted to
its own component (reads viewMode from store).

IndexRoute (routes/index.tsx) holds the file/tag/search composition,
reading from useFiles/useTags/useSearch + useUiStore. No manual useMemo
on composeVisible/sortByRank/computeFacets — React Compiler 1.0 handles.

Interim shape: SearchBar/ScanButton/StatusBar accept all props as
optional with defaults so App.tsx can call them prop-less; their
internals stay prop-driven until Tasks 8a/8b/9 migrate them to
store-driven. Test rewrites land in Task 10.
WHY: Batch H Task 8a. ScanButton owns its own mutation. onSuccess
mirrors result into useUiStore.scan (StatusBar reads from there);
notify('info', ...) gives transient UX confirmation; invalidateQueries
on files + tags; startWatch fires async. busy state derived from
both store status AND mutation.isPending so the button stays
disabled across re-renders.

tsconfig.app.json added (excludes src/__tests__) so tsc -b in the
build script type-checks only production source — test files are
owned by vitest's own TS pass. build script updated to
`tsc -b tsconfig.app.json`. tsconfig.json unchanged (ESLint still
uses it for type-aware rules across all files).

ScanButton.test failure is Task 10.
WHY: Batch H Task 8b. SearchBar owns timing + sanitisation; dispatches
setSearchQuery(raw) immediately + setDebouncedQuery(sanitised) on
300ms debounce. IndexRoute keys useSearch on debouncedQuery — IPC
fires once per debounce, not per keystroke. MIN_QUERY_LEN/buildFtsQuery/
clearedRef logic preserved verbatim. SearchBar.test failure is Task 10.
utof added 20 commits April 23, 2026 21:55
Reviewer noted the dual-field rewrite dropped aria-label="Search files"
that the prop-driven SearchBar had. Restoring as a single-line addition
so screen-readers announce the field correctly.
…rBanner

WHY: Batch H Task 9. StatusBar reads scan slice + uses useShallow for
multi-property selector (Zustand v5 crash safety). TagSidebar drops
selectedTagId/onSelect props in favor of direct store reads.
WatcherBanner deleted — watcher errors now flow as notifyError →
NotificationStack toast.
WHY: Batch H Task 10. App.test → hooks/useDomainEvents.test (assertions
on queryClient.invalidateQueries spy via renderHook + mocked
subscribeToAppEvents capture). App.compose.test → routes/index.test
(renders IndexRoute via renderWithProviders, mocks api.listFilesWithTags
+ api.listTags + api.search, asserts sidebar All-count under each
case 1-5 of the #25 composition pin). StatusBar/SearchBar/ScanButton
tests rewritten to mock store + Query providers via the new
__tests__/test-utils.tsx helper (renderWithProviders +
makeFreshQueryClient + resetUiStore + defaultUiState). TagSidebar tests
also updated to drop the removed onSelect/selectedTagId props (Task 9
left them broken; Task 10 closes that gap). All 81 tests green;
pre-Batch-H baseline was 78.

WHY fireEvent.change (not userEvent.type) in SearchBar tests: userEvent
v14 + vi.useFakeTimers + React 19 controlled-input rerender hangs on
microtask-flush dependency. fireEvent.change is synchronous + same
React-controlled-input path our prod code hits.

WHY vi.spyOn(useUiStore, "setState") (not on individual action funcs)
for the C1 regression test: spying on getState().setX swaps the
function identity, which makes consuming components see a "new"
useEffect dep on next render and re-run the effect (false positive).
setState is the single funnel every Zustand action passes through.
Reviewer flagged inconsistency between TagSidebar's
spyOn(useUiStore.getState(), "setSelectedTagId") and SearchBar's
spyOn(useUiStore, "setState") funnel. Both are valid; TagSidebar's
form is safe ONLY because the component has no useEffect deps on the
action. Documented the safety condition + the upgrade path.
WHY: Batch I needs rolling-file log appender + cross-platform log-dir
resolver in crates/app::telemetry (where CLI + desktop both call into).
tracing-appender 0.2.5 (latest stable; same tokio-rs org as
tracing-subscriber). directories v6 already in workspace from earlier
work; just adding the workspace = true line in crates/app.
WHY: Batch I Task 2. Hoists CLI's logging::init to crates/app/telemetry.rs
so desktop can share. Adds rolling-file layer (hourly rotation) alongside
stderr; JSON-by-default in release, pretty in debug; PERIMA_LOG_JSON
override stays. Log dir resolved via directories::ProjectDirs (state_dir
on Linux, data_dir fallback elsewhere — cache_dir rejected because macOS
auto-purges).

Returns WorkerGuard which the caller MUST hold for process lifetime
(dropping terminates the non-blocking flush thread). Helper truncated()
keeps span fields bounded for user-input strings (used in Task 5's
SearchUseCase instrument).

6 new unit tests. CLI's logging.rs untouched yet (Task 3 deletes it).
tracing-subscriber added to crates/app/Cargo.toml (required by init_subscriber
layered-subscriber construction; was CLI-only before).
WHY: Batch I Task 3. Removes crates/cli/src/logging.rs (functionality
hoisted to perima_app::telemetry in Task 2). main() holds the returned
WorkerGuard for process lifetime via _log_guard binding — dropping early
loses pending log lines from the non-blocking appender.

Also creates ~/.local/state/perima/logs/perima.log on first run via the
new file appender (was stderr-only before).
WHY: Batch I Task 4. Desktop binary has had no tracing subscriber init
until now — handler-side tracing::*! macros were silently dropping
events. Calls init_subscriber at the top of run() BEFORE
tauri::Builder::default() so handler events emitted during .setup are
captured. AppState gains _log_guard field (holds the WorkerGuard for
app lifetime). AppState::new loses const because WorkerGuard is not
const-constructible — non-load-bearing change.

After this task, desktop logs to the same OS-resolved log dir as CLI
(~/.local/state/perima/logs/ on Linux).
WHY: Batch I Task 5. 6 instrumentation sites: 5 UseCase::execute (Scan,
Search, Tag, Volume, Metadata) + writer dispatch. err(level="warn",
Display) auto-logs Err returns at WARN (not default ERROR — over-promotes
user-input failures).

Adds kind_str() accessor on ScanCommand/TagCommand/VolumeCommand/
MetadataCommand/WriteCmd; path_display() on ScanCommand. NO hlc field on
dispatch span — HLC is per-row, generated in handlers (per Batch C
constraints). truncated() helper from app::telemetry bounds the
SearchUseCase query field at 64 chars to prevent span bloat from long
user queries; #[allow(dead_code)] on truncated removed (Task 5 IS the
consumer).

4 continue-path tracing::warn! calls in scan.rs::execute_full preserved
(per spec §6.6 — err(...) only catches outer execute Err returns, doesn't
conflict with inner-loop continue warns).
…pture

WHY: Batch I Task 6. perima debug-report bundles the active rolling
log + last K rotated files + env context (perima version, GIT_SHA, OS,
PERIMA_LOG/PERIMA_LOG_JSON values) into a single file for attaching to
bug reports. Falls back to "(no active log file present)" if first run.

build.rs captures `git rev-parse --short HEAD` into the GIT_SHA rustc-env
at compile; falls back to "unknown" if git/.git absent (vendored builds).
cargo:rerun-if-changed=.git/HEAD picks up new commits during dev.

Integration test seeds an isolated XDG_STATE_HOME and asserts the
report-file contains the expected divider structure.
WHY: tracing-appender 0.2.5 with Rotation::HOURLY + prefix "perima"
writes filenames like "perima.YYYY-MM-DD-HH" (NOT "perima.log" or
"perima.log.YYYY..."). I-6's initial impl hardcoded the legacy plan
filenames so the active-log section always showed "(no active log file
present)" and the rotated section was always empty.

Fix: glob log_dir for files starting with "perima.", lex-sort descending
(== chronological descending given the timestamp suffix), use the lex-
greatest as the active log and skip-1-take-K for rotated.

Test assertion relaxed from "=== active log: perima.log ===" to
"=== active log: " (the dynamic filename suffix shifts hour-by-hour).
Smoke confirmed: report now contains "=== active log: perima.2026-04-24-11 ===".
…er iterations

WHY: GH #111. Previously ThumbnailGenerator::resize_image called
Resizer::new() on every iteration, discarding scratch buffers + the
CPU-extension dispatch cache. Worker-owned Resizer per spec D-1
(Approach A in 3-way alternatives table) keeps zero synchronisation
overhead, future-multi-worker-friendly, and avoids the work-stealing
hazard that would defeat thread_local!.

Resizer: Send (fast_image_resize 6.0 supertrait); owning it inside the
tokio::spawn'd async move future is sound under the multi-thread
runtime.

ThumbnailGenerator::generate + resize_image gain &mut Resizer last
positional; MetadataQueue::spawn's spawned closure allocates one
Resizer per worker task lifetime; process() forwards. 10 unit tests
adapt for new arity (one let mut resizer = Resizer::new() at test top
each). resize_image's local `resized` renamed to `output_img` and
generate's to `scaled` to satisfy clippy::similar_names (-D warnings).

resize_only_bench rewrite + read_exif log-level bump land in J-2 + J-3.
WHY: J-1 (46a8a26) added `let mut resizer = Resizer::new();` at the top
of 5 pixel-coercion tests adjacent to the existing `let resized = ...`
binding. clippy::similar_names fires under `--all-targets` (the
`just clippy` invocation) on the `resizer` vs `resized` one-character
diff. The implementer's lib-only `cargo clippy -p perima-media` did
not surface these because it skips the test module.

Renamed the 5 test-side `resized` bindings to `output_img` (matches the
production-side rename in resize_image:221). Also moved
`use fast_image_resize::Resizer;` in queue.rs above the crate-internal
`use crate::thumbnail::ThumbnailGenerator;` to match third-party-then-
crate convention.

Mechanical fix; no functional change. Verifies clean under
`cargo clippy -p perima-media --all-targets -- -D warnings` +
`cargo nextest run -p perima-media` + `cargo doc -p perima-media`.
WHY: GH #110. Previously read_exif's MediaSource::file_path failure
arm logged at debug — silently invisible at the default RUST_LOG=info
level. Permission errors / missing files / symlink loops were
indistinguishable from "file has no EXIF block" (the normal case for
PNGs and many camera-exported JPEGs).

Per spec D-2 (3-way option table; the typed-CoreError-variant + Err-
propagation alternatives rejected as over-spec): only the log-level
changes. Outer extract() still returns metadata with empty EXIF
fields; behavior preserved.

Parse-error arm UNCHANGED at debug — parse failures are normal for
many containers and would noise-spam logs at warn.
WHY: GH #111 Batch J acceptance gate. resize_only_bench was
#[ignore]-d (skipped on every nextest run) and its message body
referenced `cargo test` which is banned per CLAUDE.md
(scripts/no-cargo-test.sh).

Renamed to resize_only_bench_baseline_vs_reused_proves_amortization;
runs BOTH baseline (fresh Resizer per iter) AND reused (one Resizer)
loops in the same test invocation; asserts reused is ≥5% faster.

WHY cfg_attr(debug_assertions, ignore): in unoptimized builds the
scratch-buffer + CPU-dispatch-cache savings are invisible against the
~450ms-per-iter pixel-processing cost (measured 1.01x debug vs
1.06x release). The 5% threshold only holds when SIMD is active.
Release CI covers this via: cargo nextest run --release -p perima-media.

Audit acceptance is ≥10% throughput; 5% lower bound chosen for CI
flake margin per spec D-3. Image size: 1920×1080 (avoids nextest
slow-timeout in debug). ITERS=20 provides stable signal in release.
…135

WHY: spec reviewer empirically reproduced 27% flake rate (15-run release
sample) against the ≥1.05 speedup assertion added in 3811586. Per-run
ratio swings 0.890x — 1.049x; mean ~1.02x. Signal is dominated by
DynamicImage::clone() (~6 MB memcpy/iter) + WebP encode in resize_image()
— Resizer-internal scratch + dispatch-cache savings are a tiny fraction.
The previous cfg_attr(debug_assertions, ignore = ...) gating violated
the plan's "runs on every cargo nextest" requirement AND empirically
failed in release at 27%.

Per Batch J spec §7 option B + §8 risk #4: convert to print-only +
file follow-up. Removed the cfg_attr ignore so the test runs in BOTH
debug and release; removed the assert! so flake floor doesn't tank CI;
preserved the eprintln so a human reading CI logs can still spot
regressions by eye across runs.

GH #135 tracks the restructuring needed to re-introduce a stable
assertion (mitigations: bypass DynamicImage::clone, use Image<&[u8]>
directly, or adopt criterion).
clippy 1.95's missing_const_for_fn correctly identifies AppState::new
as a pure field-assignment body. The Batch I WHY comment about
WorkerGuard blocking const was wrong — WorkerGuard construction
happens in the caller, not inside new().
Windows runners share C:\.perima\manifest.db across parallel test
binaries. Filter manifest_files COUNT by tempdir basename so concurrent
scans don't inflate this test's row count. Linux/macOS unaffected
(volume root /.perima/ is root-only; manifest write silently fails).
Workaround for #137. taiki-e/install-action's PowerShell wrapper aborts
on windows-latest when the runner image leaks BASH_FUNC_* env vars;
their 10-iter retry doesn't catch it (verified 2/2 runs failed). The
defensive Shellshock check is correct security behavior — fix is to
bypass bash entirely on Windows. Linux/macOS unchanged.
Concurrent CLI test binaries write to shared C:\.perima\manifest.db on
GHA windows-latest runners. Basename-filter fix in 19d68c1 confirmed the
3 fixture rows are stripped by a write race before the assertion.
Linux/macOS unaffected — they exercise the else-branch graceful-
degradation path which is the more interesting invariant.
@utof
utof merged commit df93f00 into main Apr 24, 2026
4 checks passed
@utof
utof deleted the architecture-audit/v0.6.x branch April 24, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant