Skip to content

fix(sd-import): a failed import leaves a half-session that looks complete, and an empty one is deleted at next launch - #200

Merged
tylerkron merged 6 commits into
mainfrom
fix/sd-import-atomicity
Sep 2, 2026
Merged

tylerkron merged 6 commits into
mainfrom
fix/sd-import-atomicity

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review. Closes #185. Body refreshed against the final diff (527c46d), after three Qodo rounds and a shepherd pass.

What was wrong

Import a truncated or corrupt .bin from the SD card — a card pulled mid-write, a device that died during the transfer, a file a filesystem check partly recovered. You see "Import Failed", which is correct. What you are not told is that the import left data behind.

The import was never atomic. CreateSession commits the Sessions row before a single sample is parsed, FlushBatchAsync commits every 1,000-sample batch in its own transaction, and the await foreach driving both had no try/catch at all. A parser that gave up part way through therefore left a committed session plus however many batches had landed — and because startup reloads every session that has samples, that fragment came back at the next launch looking like an ordinary, complete session, with silently truncated data. Nothing crashed. The database just said something untrue.

Import a 0-byte log — what an interrupted logging session routinely leaves on a FAT card. The importer treats that as a legitimate empty session on purpose, creates the row, reports success, and shows it in the list. Then LoadPersistedLoggingSessions deletes every zero-sample session at the next launch, with no message. Two components disagreeing about whether such a session exists, and the user only ever sees the disagreement.

The two halves pull against each other: flag a failed import and the purge deletes the flag; leave the purge alone and the flag has to survive it.

How it is fixed

The mechanism that decides a session is incomplete is a new persisted Sessions.Status column (SessionStatus: Complete, Importing, ImportFailed; migration 20260902160000_AddSessionStatus defaults every existing row to Complete, which is what they are). The importer writes Importing before it parses anything, and on the way out writes:

the import… what happens to the row what the user sees
finished, wrote samples Complete + SampleCount the session, as today
threw or was cancelled after samples landed ImportFailed, samples kept "Import Failed", and the session listed with an amber INCOMPLETE IMPORT chip
threw before the first batch landed row removed, all three tables "Import Failed", and nothing left behind
parsed to zero samples row removed "The log contained no samples, so no session was created."

Partial samples are kept rather than rolled back because the source log is usually damaged in a way a re-import cannot get past — discarding the prefix is the one outcome the user cannot undo. Both heads show the chip (desktop session row, mobile session card), with a tooltip explaining that the session holds only the part of the log read before the import stopped, and AccessibilitySummary says "incomplete import" so a screen reader announces it too.

The column also covers the case no catch block can: a process killed mid-import leaves the row saying Importing, and startup flags it as failed. That is the only reason the state is persisted rather than kept in memory.

The zero-sample question is settled by the invariant that makes the whole thing small: a persisted session row always either has samples or is being written right now. Under it —

  • the empty log persists nothing, so there is no row for the purge to delete silently, and the dialog says the log was empty instead of claiming an import that vanishes overnight (Import All records it as a skip with its own guidance line rather than counting it among the imported);
  • a failed import's flag always sits on a row that has samples, so the purge's Samples.Any filter never sees it;
  • the purge keeps its actual job — deleting the empty rows an abandoned logging session leaves after a crash — and gains one exemption: rows an import is currently writing. That is the hazard GetLoggingSessionsSnapshot was added to dodge, now impossible rather than avoided by which method a caller happens to pick. Its doc comment is updated to say so.

Session IDs. The importer allocated MAX(Sessions.ID) + 1 — the narrow version LoggingManager's own comment warns against, since it can hand out an ID that orphan Samples / SessionDeviceMetadata rows still reference, after which the composite PK rejects the insert. Both callers now go through one SessionIdAllocator.

Overwrite. ImportOptions.OverwriteExistingSession deleted the session it replaces in CreateSession, before anything had been parsed — so an overwrite that then failed or produced nothing destroyed the user's existing session and put nothing in its place. The replacement is now imported alongside the original and the original dropped only once a complete replacement exists; the sessions it is entitled to replace are chosen when the import starts, excluding rows another import owns, so cleanup can never take a concurrent import's row and the batches it has already committed. (No production call site sets this option today — all three pass options: null — but the code is on the same path as everything above.)

Two smaller changes this needed. The final in-memory SampleCount write now Posts to the UI thread instead of Invoke-ing it: nothing waits on the result, the caller only adds the session to the list after this returns, and blocking the importing thread on a dispatcher loop that may not be running is a hang — it is what made the import untestable at all. And ImportFromDeviceAsync no longer deletes the file from the device unless a finished session came of it.

The upgrade path

A new persisted column means an existing user's database has to survive meeting it, and this repo has been burned twice by database-state bugs (#181, #196). What happens:

  • The migration is applied automatically. Nothing new was wired up: Sessions.Status is picked up by the existing startup path — DatabaseMigrator.PrepareMigration seeds __EFMigrationsHistory for pre-migration databases and reports the column as pending, and ApplyMigrations (which backs the file up first, and rolls back on failure) runs it. The mobile heads reach it through MigrateWithCorruptionRecovery.
  • An existing row reads as Complete, not incomplete. The column is NOT NULL DEFAULT 0 and Complete = 0, so every row written before it existed migrates to "finished" — which is what those rows are. A session that imported fine a year ago does not start wearing an INCOMPLETE IMPORT chip after the upgrade.
  • It is reversible. Down drops the column; SQLite rebuilds the table, and the sessions and their samples come through intact.

All three are now tested rather than reasoned about — see below.

Verification

  • Tests: 18 new — 14 in Daqifi.Avalonia.Tests/Loggers/SdCardImportAtomicityTests.cs, 3 in SessionStatusUpgradeTests.cs, 1 in ImportAllSummaryTests. Full suite 275 passed / 0 failed (257 on main, whose head 4f96f77 is this branch's merge base, so 275 is the merge result rather than a parallel measurement). They run against a real SQLite file migrated by the app's own DatabaseMigrator, driving ImportSessionAsync with a parsed log whose sample stream throws, cancels, or ends empty, and asserting on what is actually committed.
  • The upgrade is tested from the shipped schema, not from a fresh database. SessionStatusUpgradeTests builds the old schema out of the app's own migrations stopped at the last one that shipped (IMigrator.Migrate("20260415000000_AddSessionSampleCount")) rather than a hand-written CREATE TABLE that could drift from it, writes rows by raw SQL naming only the columns that existed then, and only then hands the file to PrepareMigration / ApplyMigrations / LoadPersistedLoggingSessions. It asserts the Status column really is absent first, so a fixture mistake cannot make it pass for the wrong reason.
  • Each fix was checked to fail without itself, by neutering the production change while keeping the new API so the suite still compiled:
    • the core change (create as Complete, no finalisation in the catch, unconditional purge) → Failed: 7, Passed: 3 of the original ten. The three survivors are the successful-import case, the abandoned-empty-session purge (a guard on behaviour that must not change) and the ID allocation, which was not part of what was neutered.
    • restoring the up-front overwrite delete → all four overwrite tests fail.
    • restoring only the round-2 behaviour (choose replacement targets after the import, without excluding Importing rows) → exactly An_overwrite_never_replaces_a_session_another_import_is_writing fails, the other three still pass.
    • changing the migration's defaultValue from 0 to 2 → Failed: 1, Passed: 2, exactly the upgrade test that asserts a pre-existing session still reads Complete; the other two are independent of the default.
    • the ImportAllSummaryTests case is not covered by this exercise: removing RecordNotice stops the suite compiling, so there is nothing to run.
  • Build gate green: desktop Daqifi.Avalonia.Desktop, and iOS Release / iossimulator-arm64 (restore without -r, build with it and --no-restore), re-run on every push. No packages.lock.json drift. The Android head was not built — the workload is absent on this machine; CI covers it.
  • No warning-count claim — none was measured.
  • XAML: only new members are bound (IsIncompleteImport, IncompleteImportTooltip); nothing was renamed or removed, so no reflection-bound view can break silently. StatusAmber is defined in both of DesignTokens.axaml's theme dictionaries (Dark and Default, identical values — the visual system is dark-only), so the lookup cannot come back empty. XAML bindings are not compile-checked here, so the chip was additionally rendered rather than reasoned about — see below.
  • Not exercised against hardware — no board was attached for this work. The importer's device path (ImportFromDeviceAsync) is unchanged apart from the delete-after-unsuccessful-import guard and a log line; everything tested here is the parse-and-commit half, driven directly.

The chip, rendered

A green build cannot tell you a new label appears — a blank cell is the classic silent failure here. So both heads were captured by tools/parity-audit/AvaloniaCapture, which boots the real app headless against a seeded database (a Complete session and an ImportFailed one, each with samples, in a throwaway DAQIFI_DATA_DIR). Screenshots in the comment below.

That is what caught the one real defect the shepherd pass found: the mobile chip rendered as ordinary grey metadata, indistinguishable from the date and SAMPLES pills beside it, for two independent and individually silent reasons —

  • Border.chip sets no BorderThickness, and Border's default is 0, so the chip's BorderBrush="{DynamicResource StatusAmber}" painted nothing; and
  • Border.chip > TextBlock already sets Foreground, and the inline {DynamicResource StatusAmber} on the child lost to it, so the text fell back to the muted grey.

Fixed by pairing the brush with an explicit BorderThickness="1" (as the other mobile views that do this already do) and adding a TextBlock.chipWarn style beside the chipValue one that established the pattern — with the reason it must be a style, not an inline value, written down next to it. Substituting a literal magenta proved the inline value was the thing being overridden rather than the resource failing to resolve. The desktop pane never used the chip class and was correct throughout.

Qodo

Three rounds during the build, six findings, all triaged; every inline thread answered and resolved.

  • Round 1 (7086045): Overwrite loses existing session (High), Finalization failure reports success (High), Cleanup failure leaves hidden row (Medium) conceded and fixed in 084d1ea. Startup relabels live imports (Low) conceded in part — the read-then-save race in MarkAbandonedImportsFailed is now one conditional UPDATE; the ownership-token/lease half is rebutted on fix(startup): a damaged DAQiFiDatabase.db made the app permanently unopenable #181's record (the app has no cross-process database ownership guard at all, and a lease this had to wait out would leave a crashed import's session invisible until it expired — the bug being fixed, with a timer on it). Recorded in the code comment, not only in the thread.
  • Round 2 (b771263): two new findings, both on the round-1 fix rather than the original change — Overwrite deletes active imports (High) and Failed overwrite reports success (Medium). Both conceded and fixed in 7b317c3.
  • Round 3 (7b317c3): zero findings, zero unresolved threads, re-checked after a settle interval.

Deliberately not done

No shared quarantine/relocate helper. This is the fifth member of the "a partial operation reported success" family, and unifying the four hand-written copies is a deferred target blocked on #195 and #196; this PR adds no new copy of that pattern.

Importing a truncated or garbage .bin from an SD card showed "Import Failed",
which was correct, and said nothing about the data it had left behind. The
import was never atomic: CreateSession commits the Sessions row before a single
sample is parsed, every 1,000-sample batch commits in its own transaction, and
the parse loop had no try/catch at all. So a parser that gave up part way
through left a committed session plus however many batches had landed -- and
because startup reloads whatever has samples, that fragment came back at the
next launch as an ordinary, complete-looking session with silently truncated
data. Nothing crashed; the database simply said something untrue.

Sessions now carry a Status (new column, defaulting every existing row to
Complete). The importer writes Importing before it parses anything, and on the
way out writes Complete, or ImportFailed if the parse threw or was cancelled
after samples had landed. Those samples are KEPT -- the source log is usually
damaged in a way a re-import cannot get past, so discarding the prefix would be
the one outcome the user cannot undo -- and both session lists show the row with
an amber INCOMPLETE IMPORT chip, tooltip and screen-reader summary instead of
presenting it as finished. A failure before the first batch landed keeps
nothing: there is no data to preserve and no marker worth showing, so the row is
removed and the database goes back to how it was.

The status column also covers the case no catch block can: a process killed
mid-import leaves the row saying Importing, and startup flags it.

Second half of the ticket, the zero-sample session. The importer deliberately
treated a 0-byte log as a legitimate empty session and reported success, while
LoadPersistedLoggingSessions deleted zero-sample rows at the next launch without
a word -- two components disagreeing about whether such a session exists. They
now agree: a persisted row always either has samples or is being written. An
empty log persists nothing and the dialog says "contained no samples, so no
session was created" rather than claiming an import that vanishes overnight;
Import All records it as a skip instead of counting it. The purge keeps its
actual job -- deleting the empty rows an abandoned logging session leaves -- and
gains an exemption for rows an import is writing, which is the hazard
GetLoggingSessionsSnapshot was added to dodge.

Also: the importer allocated MAX(Sessions.ID) + 1, the narrow version
LoggingManager's own comment warns against, because it can hand out an ID orphan
rows still reference. Both now go through one SessionIdAllocator.

Two smaller changes the above needed. The final in-memory SampleCount write now
Posts to the UI thread rather than Invoke-ing: nothing waits on it, and blocking
the importing thread on a dispatcher loop that may not be running is a hang --
it is what made the import untestable. And ImportFromDeviceAsync no longer
deletes the file from the device after a log that produced no samples.

Tests: 10 new (267 total, was 257). Seven of the ten fail against the old
behaviour, verified by neutering the fix and re-running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make SD card imports recoverable and visibly incomplete

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Persist import lifecycle status so partial sessions never masquerade as complete.
• Preserve committed prefixes while removing failed-before-write and empty import rows.
• Surface incomplete imports across desktop, mobile, tooltips, and accessibility summaries.
Diagram

stateDiagram-v2
  direction TD
  state "Import request" as Request
  state "Importing row" as Importing
  state "Complete session" as Complete
  state "Failed import" as Failed
  state "Removed row" as Removed
  state "Session lists" as Lists
  state "No session" as NoSession
  Request --> Importing: create row
  Importing --> Complete: samples finish
  Importing --> Failed: partial failure
  Importing --> Failed: restart recovery
  Importing --> Removed: zero samples
  Importing --> Removed: failure before batch
  Complete --> Lists: standard row
  Failed --> Lists: warning chip
  Removed --> NoSession: omit result
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Single import transaction
  • ➕ Provides strict all-or-nothing database semantics.
  • ➕ Avoids persisted intermediate lifecycle states.
  • ➖ Discards the recoverable prefix of damaged logs.
  • ➖ Creates a potentially large, long-lived SQLite transaction.
  • ➖ Cannot communicate partial data availability to users.
2. Dedicated staging tables
  • ➕ Keeps incomplete data separate from normal sessions.
  • ➕ Could support explicit recovery or promotion workflows.
  • ➖ Requires duplicate schema, promotion logic, and additional migrations.
  • ➖ Adds cleanup and synchronization complexity across related tables.
  • ➖ Still needs persisted state to recover process termination.

Recommendation: Keep the PR's persisted lifecycle-status approach. It preserves otherwise unrecoverable partial samples, survives process termination, and integrates with existing session storage without a parallel staging model. A full transaction was considered but conflicts with the deliberate requirement to retain useful prefixes; staging tables would add disproportionate schema and recovery complexity.

Files changed (13) +1280 / -166

Enhancement (2) +93 / -0
LoggingSession.csExpose persisted import status and warning metadata +48/-0

Expose persisted import status and warning metadata

• Adds the persisted Status property plus computed incomplete-state, tooltip, and accessibility values. Status changes now notify all dependent UI bindings.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingSession.cs

SessionStatus.csDefine the persisted session import lifecycle +45/-0

Define the persisted session import lifecycle

• Adds Complete, Importing, and ImportFailed states with stable integer values. The enum establishes the invariant governing persistence, startup recovery, and incomplete-session presentation.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionStatus.cs

Bug fix (7) +500 / -166
LoggingManager.csRecover abandoned imports and protect active rows +73/-35

Recover abandoned imports and protect active rows

• Exempts Importing sessions from zero-sample startup cleanup and converts abandoned Importing rows to ImportFailed. It also adopts the shared allocator so live logging and SD imports use identical collision-safe session IDs.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs

SdCardSessionImporter.csPersist explicit outcomes for every import path +304/-125

Persist explicit outcomes for every import path

• Marks new sessions Importing, finalizes successful or partially failed imports, and transactionally removes rows when no samples land. Results now expose imported counts and persistence state, while device deletion is prevented for empty logs.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs

SessionIdAllocator.csCentralize collision-safe session ID allocation +52/-0

Centralize collision-safe session ID allocation

• Introduces one allocator that selects the next ID across Sessions, Samples, and SessionDeviceMetadata. This prevents orphaned related rows from colliding with imported or live-logging sessions.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionIdAllocator.cs

LoggedDataPanePrototype.axamlFlag incomplete imports in the desktop session row +19/-0

Flag incomplete imports in the desktop session row

• Adds an amber INCOMPLETE IMPORT chip with status-driven visibility and explanatory tooltip to the desktop logged-data prototype.

Daqifi.Avalonia/Daqifi.Desktop/View/Prototype/LoggedDataPanePrototype.axaml

DaqifiViewModel.csHandle empty local-file imports accurately +16/-4

Handle empty local-file imports accurately

• Only adds persisted import results to the session list. Empty logs now receive a message explaining that no session was created.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs

DeviceLogsViewModel.csTreat empty device logs as skipped imports +27/-2

Treat empty device logs as skipped imports

• Avoids listing empty-log results as sessions and reports their outcome accurately. Bulk imports record empty logs as skipped instead of inflating the imported-session count.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DeviceLogsViewModel.cs

LoggedSessionsMobileView.axamlFlag incomplete imports in mobile session rows +9/-0

Flag incomplete imports in mobile session rows

• Adds the amber INCOMPLETE IMPORT chip and explanatory tooltip to the mobile logged-session list.

Daqifi.Avalonia/Views/Mobile/LoggedSessionsMobileView.axaml

Tests (1) +402 / -0
SdCardImportAtomicityTests.csCover SD import persistence and recovery outcomes +402/-0

Cover SD import persistence and recovery outcomes

• Adds SQLite-backed integration tests for successful, empty, failed, cancelled, and process-abandoned imports. The suite also verifies startup purge behavior and collision-safe allocation when orphan sample rows exist.

Daqifi.Avalonia.Tests/Loggers/SdCardImportAtomicityTests.cs

Other (3) +285 / -0
20260902160000_AddSessionStatus.Designer.csDescribe the model produced by the status migration +243/-0

Describe the model produced by the status migration

• Adds the generated migration target model containing the integer Sessions.Status property.

Daqifi.Avalonia/Daqifi.Desktop/Migrations/20260902160000_AddSessionStatus.Designer.cs

20260902160000_AddSessionStatus.csAdd the Sessions status column +39/-0

Add the Sessions status column

• Adds a non-nullable integer Status column defaulting to Complete for existing sessions. The down migration removes the column.

Daqifi.Avalonia/Daqifi.Desktop/Migrations/20260902160000_AddSessionStatus.cs

LoggingContextModelSnapshot.csRecord session status in the EF model snapshot +3/-0

Record session status in the EF model snapshot

• Updates the current Entity Framework model snapshot with the persisted Sessions.Status property.

Daqifi.Avalonia/Daqifi.Desktop/Migrations/LoggingContextModelSnapshot.cs

@qodo-code-review

qodo-code-review Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Overwrite deletes active imports ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
ReplaceOverwrittenSessions selects every same-name session without checking status or ownership,
so one completed overwrite can delete another concurrent import's Importing row and committed
batches. The second import then cannot finalize and may lose all samples already written.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R802-805]

+                superseded = context.Sessions
+                    .AsNoTracking()
+                    .Where(s => s.Name == session.Name && s.ID != session.ID)
+                    .Select(s => s.ID)
Relevance

●●● Strong

Recent history accepts concurrency safeguards; cleanup must not delete active imports or their
committed samples.

PR-#178
PR-#155

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Each import persists an Importing session before parsing, while replacement cleanup later queries
all other rows sharing its name and deletes their sessions and samples. If another import is among
those rows, its subsequent conditional finalization finds no session and reports that its samples
may be orphaned.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[602-635]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[660-678]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[790-817]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Overwrite cleanup can select and delete a concurrent same-name import, removing its session and committed samples while it is still running.

## Issue Context
The superseded IDs are discovered only after the replacement completes and the query includes `Importing` rows. Capture the intended pre-existing replacement targets before creating the new session, or otherwise add ownership/status synchronization so cleanup cannot target another active import.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[365-377]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[790-817]
- Daqifi.Avalonia.Tests/Loggers/SdCardImportAtomicityTests.cs[173-241]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Finalization failure reports success ✓ Resolved 🐞 Bug ☼ Reliability
Description
FinalizeSession swallows a missing row, database failure, or dispatcher-post failure, so the
import still returns SessionPersisted == true even though its row may remain Importing or not
exist. Device imports may then delete the source file and callers add a misleading session to the
UI.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R663-666]

+        catch (Exception ex)
+        {
+            _logger.Error(ex, $"Failed to record the outcome of the import into session {session.ID}");
+        }
Relevance

●●● Strong

Reporting persistence success after finalization failure is a concrete reliability error affecting
source deletion and UI state.

PR-#181

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The status/count update is the only operation that converts the persisted row from Importing, but
all exceptions are swallowed and a null lookup is accepted. The result's persisted flag depends only
on sample count, and the device path uses that flag to delete the original file.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-667]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[416-427]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[211-217]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Successful imports are reported as persisted even when the database outcome could not be finalized. This can cause UI state to disagree with storage and permit deletion of the source SD file.

## Issue Context
Treat a missing session or failed `Status`/`SampleCount` save as an import failure, or return an explicit finalization outcome that prevents source deletion and UI insertion. Handle dispatcher notification separately from the required database commit.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-667]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[211-217]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Overwrite loses existing session ✓ Resolved 🐞 Bug ≡ Correctness
Description
With OverwriteExistingSession enabled, CreateSession deletes the existing complete session
before parsing, then the new zero-sample and pre-first-batch failure paths delete the replacement.
An empty or immediately failing overwrite therefore permanently removes the user's existing session
and leaves nothing in its place.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R409-412]

+            _logger.Warning(
+                $"No samples found in SD card file '{logSession.FileName}'. " +
+                $"DeviceConfig present: {config != null}. Removing the empty session row.");
+            RemoveSessionRow(session);
Relevance

●●● Strong

Destructive overwrite failure is a concrete data-loss bug aligned with the PR’s stated import-safety
intent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The overwrite path commits deletion of the existing same-name session before the replacement is
created. The added zero-sample cleanup and failure-before-any-committed-sample cleanup subsequently
remove that replacement, while explicit child deletion/cascades make the old data unrecoverable.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[581-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-427]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[543-567]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-705]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An overwrite deletes the existing session before parsing. The new empty/failure cleanup then removes the replacement, permanently losing the original session.

## Issue Context
Import into a temporary/new session first. Delete or replace the old session only after parsing and finalization succeed; retain the original when the import is empty or fails.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-419]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[543-564]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[581-610]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Failed overwrite reports success ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
ReplaceOverwrittenSessions ignores RemoveSessions returning false, so a failed cleanup leaves
the superseded session alongside the replacement while callers still display a successful import.
The requested overwrite therefore silently becomes a duplicate.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R814-817]

+            _logger.Information(
+                $"Overwrite import finished; replacing session(s) {string.Join(", ", superseded)} " +
+                $"named '{session.Name}' with {session.ID}");
+            RemoveSessions(superseded);
Relevance

●●● Strong

Recent accepted precedents require propagating cleanup failures instead of reporting successful
replacement despite retained data.

PR-#192
PR-#181

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
RemoveSessions documents and returns false when its transactional deletion fails, but the
overwrite caller ignores the value. Because SessionPersisted was already set from successful
replacement finalization, both import UIs add the replacement and announce success while the old row
remains.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[728-775]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[790-824]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DeviceLogsViewModel.cs[551-575]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[1332-1354]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Overwrite cleanup failures are swallowed and the import is still presented as a successful replacement even though the old session remains.

## Issue Context
`RemoveSessions` deliberately returns whether deletion succeeded, but `ReplaceOverwrittenSessions` discards that result. Propagate the cleanup outcome to `SdCardImportResult` and show accurate warning guidance, or otherwise make unsuccessful replacement cleanup observable without hiding the successfully persisted new session.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[751-775]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[790-824]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[1332-1356]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DeviceLogsViewModel.cs[551-575]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Cleanup failure leaves hidden row ✓ Resolved 🐞 Bug ☼ Reliability
Description
RemoveSessionRow swallows every deletion failure, after which a zero-sample import returns
SessionPersisted == false and the UI says no session was created. The committed Importing row
can nevertheless remain in the database and is only relabeled or purged on later loads.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R706-709]

+        catch (Exception ex)
+        {
+            // Leaves the row saying Importing, which the next launch flags as an incomplete
+            // import. Visible and wrong-ish rather than invisible and wrong.
Relevance

●● Moderate

Cleanup is intentionally best-effort, but suppressed deletion failures leave misleading persisted
state.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Session creation is committed before parsing. The cleanup catches and suppresses deletion errors,
while callers derive SessionPersisted solely from a zero sample count and consequently report that
no session was created.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-711]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DeviceLogsViewModel.cs[554-567]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Failure to remove an empty import row is logged and ignored, causing the returned result and user message to contradict persisted storage.

## Issue Context
Make deletion success part of the import result or propagate cleanup failure. Do not report that no session exists unless the transaction committed and actually deleted the expected row.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-711]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-427]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Startup relabels live imports ✓ Resolved 🐞 Bug ≡ Correctness
Description
MarkAbandonedImportsFailed changes every Importing row without ownership, age, or
persisted-status checks, allowing startup in one app instance to overwrite another instance’s
concurrent Complete update with ImportFailed. The completed session can then show an
incomplete-import warning despite containing the completed log, while a subsequent load may purge a
still-live zero-sample row after it is relabeled.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[R789-791]

+            var abandoned = context.Sessions
+                .Where(session => session.Status == SessionStatus.Importing)
+                .ToList();
Relevance

● Weak

Recent precedent rejects cross-process ownership hardening when existing best-effort behavior is
intentional.

PR-#181

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Imports commit their row as Importing before parsing and finalize it later as Complete through a
separate context, while startup tracks all rows observed as Importing and later updates them
unconditionally. Because the model has no concurrency token or cross-process ownership guard,
another process can complete an import between startup’s read and save, after which the stale
startup context overwrites Complete with ImportFailed; the purge then deletes any empty row once
it is no longer labeled Importing, and the code’s remarks explicitly acknowledge that another
process may be importing concurrently.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[720-731]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[769-803]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-641]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[775-803]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-642]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[755-761]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Startup recovery cannot distinguish a crashed import from one actively owned by another app instance. Prevent `MarkAbandonedImportsFailed` from overwriting an import that completed after startup observed it as `Importing`, or from causing a still-live import row to be purged.

## Issue Context
`MarkAbandonedImportsFailed` materializes importing sessions and later writes `ImportFailed` unconditionally through tracked entities. A concurrent process can finalize one of those rows as `Complete` using a separate context between the read and `SaveChanges`, after which the stale startup context overwrites the completed status; the code explicitly acknowledges that there is no cross-process database ownership guard.

Persist an ownership token or lease, otherwise enforce single-process database ownership, or use an equivalent concurrency mechanism. Recovery should claim only expired imports, and status transitions should use atomic conditional database updates that modify a row only while its persisted status remains `Importing`, rather than applying a stale tracked-entity update.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[720-731]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[785-803]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-642]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: 🚀 Fast: The push adds migration coverage and makes a localized Avalonia styling adjustment, with no new runtime import or persistence logic and contained blast radius.

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 527c46d

Results up to commit 7086045 🧠 Deep


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Finalization failure reports success ✓ Resolved 🐞 Bug ☼ Reliability
Description
FinalizeSession swallows a missing row, database failure, or dispatcher-post failure, so the
import still returns SessionPersisted == true even though its row may remain Importing or not
exist. Device imports may then delete the source file and callers add a misleading session to the
UI.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R663-666]

+        catch (Exception ex)
+        {
+            _logger.Error(ex, $"Failed to record the outcome of the import into session {session.ID}");
+        }
Relevance

●●● Strong

Reporting persistence success after finalization failure is a concrete reliability error affecting
source deletion and UI state.

PR-#181

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The status/count update is the only operation that converts the persisted row from Importing, but
all exceptions are swallowed and a null lookup is accepted. The result's persisted flag depends only
on sample count, and the device path uses that flag to delete the original file.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-667]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[416-427]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[211-217]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Successful imports are reported as persisted even when the database outcome could not be finalized. This can cause UI state to disagree with storage and permit deletion of the source SD file.

## Issue Context
Treat a missing session or failed `Status`/`SampleCount` save as an import failure, or return an explicit finalization outcome that prevents source deletion and UI insertion. Handle dispatcher notification separately from the required database commit.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-667]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[211-217]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Overwrite loses existing session ✓ Resolved 🐞 Bug ≡ Correctness
Description
With OverwriteExistingSession enabled, CreateSession deletes the existing complete session
before parsing, then the new zero-sample and pre-first-batch failure paths delete the replacement.
An empty or immediately failing overwrite therefore permanently removes the user's existing session
and leaves nothing in its place.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R409-412]

+            _logger.Warning(
+                $"No samples found in SD card file '{logSession.FileName}'. " +
+                $"DeviceConfig present: {config != null}. Removing the empty session row.");
+            RemoveSessionRow(session);
Relevance

●●● Strong

Destructive overwrite failure is a concrete data-loss bug aligned with the PR’s stated import-safety
intent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The overwrite path commits deletion of the existing same-name session before the replacement is
created. The added zero-sample cleanup and failure-before-any-committed-sample cleanup subsequently
remove that replacement, while explicit child deletion/cascades make the old data unrecoverable.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[581-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-427]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[543-567]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-705]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An overwrite deletes the existing session before parsing. The new empty/failure cleanup then removes the replacement, permanently losing the original session.

## Issue Context
Import into a temporary/new session first. Delete or replace the old session only after parsing and finalization succeed; retain the original when the import is empty or fails.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-419]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[543-564]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[581-610]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
3. Cleanup failure leaves hidden row ✓ Resolved 🐞 Bug ☼ Reliability
Description
RemoveSessionRow swallows every deletion failure, after which a zero-sample import returns
SessionPersisted == false and the UI says no session was created. The committed Importing row
can nevertheless remain in the database and is only relabeled or purged on later loads.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[R706-709]

+        catch (Exception ex)
+        {
+            // Leaves the row saying Importing, which the next launch flags as an incomplete
+            // import. Visible and wrong-ish rather than invisible and wrong.
Relevance

●● Moderate

Cleanup is intentionally best-effort, but suppressed deletion failures leave misleading persisted
state.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Session creation is committed before parsing. The cleanup catches and suppresses deletion errors,
while callers derive SessionPersisted solely from a zero sample count and consequently report that
no session was created.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-711]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DeviceLogsViewModel.cs[554-567]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Failure to remove an empty import row is logged and ignored, causing the returned result and user message to contradict persisted storage.

## Issue Context
Make deletion success part of the import result or propagate cleanup failure. Do not report that no session exists unless the transaction committed and actually deleted the expected row.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[693-711]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[400-427]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[816-827]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
4. Startup relabels live imports ✓ Resolved 🐞 Bug ≡ Correctness
Description
MarkAbandonedImportsFailed changes every Importing row without ownership, age, or
persisted-status checks, allowing startup in one app instance to overwrite another instance’s
concurrent Complete update with ImportFailed. The completed session can then show an
incomplete-import warning despite containing the completed log, while a subsequent load may purge a
still-live zero-sample row after it is relabeled.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[R789-791]

+            var abandoned = context.Sessions
+                .Where(session => session.Status == SessionStatus.Importing)
+                .ToList();
Relevance

● Weak

Recent precedent rejects cross-process ownership hardening when existing best-effort behavior is
intentional.

PR-#181

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Imports commit their row as Importing before parsing and finalize it later as Complete through a
separate context, while startup tracks all rows observed as Importing and later updates them
unconditionally. Because the model has no concurrency token or cross-process ownership guard,
another process can complete an import between startup’s read and save, after which the stale
startup context overwrites Complete with ImportFailed; the purge then deletes any empty row once
it is no longer labeled Importing, and the code’s remarks explicitly acknowledge that another
process may be importing concurrently.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[720-731]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[769-803]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-641]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[775-803]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-642]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[755-761]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Startup recovery cannot distinguish a crashed import from one actively owned by another app instance. Prevent `MarkAbandonedImportsFailed` from overwriting an import that completed after startup observed it as `Importing`, or from causing a still-live import row to be purged.

## Issue Context
`MarkAbandonedImportsFailed` materializes importing sessions and later writes `ImportFailed` unconditionally through tracked entities. A concurrent process can finalize one of those rows as `Complete` using a separate context between the read and `SaveChanges`, after which the stale startup context overwrites the completed status; the code explicitly acknowledges that there is no cross-process database ownership guard.

Persist an ownership token or lease, otherwise enforce single-process database ownership, or use an equivalent concurrency mechanism. Recovery should claim only expired imports, and status transitions should use atomic conditional database updates that modify a row only while its persisted status remains `Importing`, rather than applying a stale tracked-entity update.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[720-731]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/LoggingManager.cs[785-803]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[598-610]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SdCardSessionImporter.cs[631-642]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

…ort what it actually did

All four Qodo round-1 findings.

1. Overwrite loses the existing session (High) — conceded, and the sharpest of
   the four. CreateSession deleted the session an OverwriteExistingSession
   import replaces BEFORE parsing anything, so the new "remove the row when the
   import produced nothing" paths finished the job: an empty or immediately
   failing overwrite destroyed the user's existing session and put nothing in
   its place. The replacement is now imported alongside the original, and the
   original is dropped only once a complete replacement exists — the same
   "delete only after success" discipline as the neighbouring PRs in this
   family. Three tests, all failing before the change.

2. Finalization failure reports success (High) — conceded. FinalizeSession
   swallowed a missing row and every database error, and SessionPersisted was
   derived from the sample count, so an import whose status write never landed
   still reported success: the device path would delete the source file and the
   caller would list a session the next launch flags as incomplete. It is now a
   single conditional UPDATE whose row count says whether it happened, and
   SessionPersisted is an explicit result field set from that.

3. Cleanup failure leaves a hidden row (Medium) — conceded. RemoveSessions
   returns whether the delete committed, and the user is only told no session
   was created when it did. The result carries OutcomeGuidance, one sentence
   written where the outcome is known and read by all three import call sites,
   so an empty log, an uncleanable leftover and an unrecorded session each say
   the right thing and the three dialogs cannot drift (the pattern
   DatabaseMigrator.DescribeQuarantineForUser set).

4. Startup relabels live imports (Low) — conceded in part.
   MarkAbandonedImportsFailed was a read followed by a save of tracked entities,
   so an import finishing in another instance between the two had its Complete
   overwritten by a stale ImportFailed. It is now one conditional UPDATE, which
   closes that window entirely. The remainder of the finding — an ownership
   token or lease so recovery only claims expired imports — is rebutted on the
   record set by #181: the app has no cross-process database ownership guard of
   any kind, introducing one is an app-wide mechanism rather than part of this
   fix, and Qodo's own relevance rating on that half is Weak.

Tests: 13 (was 10); 270 total, was 257 on main. Desktop and iOS Release builds
green, no packages.lock.json drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 1 triaged; all four findings addressed in 084d1ea. Threads 1–3 answered and resolved inline. Finding 4 (Startup relabels live imports, Low) has no inline thread, so the response is here:

Conceded in part, and the part that holds is now fixed. MarkAbandonedImportsFailed was a read followed by a SaveChanges over tracked entities, so an import finishing in another instance between the two would have its Complete overwritten by a stale ImportFailed. That is a real read-modify-write window and it is gone: the method is now one conditional ExecuteUpdate on Status == Importing, so the only rows it touches are the ones the database still shows as importing at that instant, and a live import writes its own status afterwards. The same change went into FinalizeSession for the same reason.

Rebutted: the ownership token / lease half. The suggestion is to persist an ownership token or lease, or enforce single-process database ownership, so recovery claims only expired imports. That is the same request PR #181 took in its round 3, and the answer has not changed:

  • This app has no cross-process database ownership guard of any kind — no named mutex, no lock file, no single-instance gate. Two instances already share this database today, entirely independently of anything in this PR. Introducing process-wide ownership is a new app-wide mechanism, not part of an SD-import fix, and it would need to govern every writer (the streaming DatabaseLogger, Delete All, the migrator) rather than just this one.
  • It would also be the wrong trade on its own terms. A lease that recovery waits out means a session left by a crashed import stays invisible and unflagged until the lease expires — which is the bug this PR exists to close, reintroduced with a timer on it.
  • What bounds the exposure in practice is that LoadPersistedLoggingSessions runs once, at desktop startup, before this process can import anything, so the only import it can ever race is another instance's. In that case the mislabel is transient and self-correcting: the live import rewrites Complete when it finishes. The residual case Qodo names — a second launch purging a still-live zero-sample row after the first relabelled it — needs two launches inside one import, and is the same class of exposure the shared-database-with-no-owner design already carries.

Qodo's own relevance rating on this half is ● Weak, citing #181 as the precedent that rejects exactly this hardening. Recorded in the code comment on MarkAbandonedImportsFailed rather than only here.

/agentic_review

The rebuttal to Qodo's round-1 finding 4 belongs beside the code it defends,
not only in the PR conversation: MarkAbandonedImportsFailed deliberately does
not prove an import is dead before flagging it, because the app has no
cross-process database ownership guard at all and a lease this had to wait out
would leave a crashed import's session invisible until it expired -- the bug
this exists to close, with a timer on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b771263

…claim it replaced anything

Both Qodo round-2 findings, and both are on the ReplaceOverwrittenSessions I
added in round 1 rather than on the original change.

1. Overwrite deletes active imports (High) — conceded. The replacement targets
   were chosen after the import finished, by session name. Two imports of the
   same file produce the same name, so that list could include a concurrent
   import's Importing row, and deleting it destroys the batches that import has
   already committed. The list is now taken in CreateSession, before this
   session exists, and excludes rows an import owns at that instant: it can only
   ever name sessions that predate this import and that nothing is writing.
   Nothing puts an existing row back into Importing, so a row that is not
   importing then cannot become so later.

2. Failed overwrite reports success (Medium) — conceded. RemoveSessions returns
   whether the delete committed and ReplaceOverwrittenSessions discarded it, so
   a failed cleanup left the superseded session beside the replacement while the
   dialog said the import succeeded — the user asked for a replacement and got a
   duplicate, silently. The outcome now reaches SdCardImportResult.
   OutcomeGuidance, which becomes "anything worth saying beyond it worked"
   rather than "why it did not work": SessionPersisted stays true, because the
   new session is committed and correct, and the message adds a line telling
   them the old one is still there. ImportAllOutcome gains Notices for the batch
   equivalent — deduped like the skip advice, but for files that DID import, so
   it is not counted as a skip.

Tests: 15 (was 13); 272 total, was 257 on main. Desktop and iOS Release builds
green, no packages.lock.json drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 7b317c3

tylerkron and others added 2 commits September 2, 2026 11:56
…y grey metadata

Caught by rendering it, not by reading it. The desktop chip is unmistakable —
amber text in an amber outline. The mobile one, on the same data, came out in
the same muted grey as the date and SAMPLES chips beside it, with no border at
all: on a phone the only thing marking a session as the fragment of a failed
import read as one more metadata pill.

Two independent causes, both silent, neither visible to the compiler:

  - `Border.chip` sets no BorderThickness, and Border's default is 0, so the
    chip's `BorderBrush="{DynamicResource StatusAmber}"` painted nothing. It
    was a dead property. The mobile views that already do this pair the brush
    with an explicit `BorderThickness="1"` (DeviceLogsMobileView,
    DevicesMobileView); this one now does too.

  - `Border.chip > TextBlock` already sets Foreground to TextSecondary, and the
    inline `{DynamicResource StatusAmber}` on the child lost to it, so the text
    fell back to grey. The fix is the pattern the neighbouring chipValue style
    established for exactly this: override one style with a later one. New
    `TextBlock.chipWarn` class, and the reason it has to be a style rather than
    an inline value is written down beside it so the next person does not
    rediscover it the same way.

Verified by capture, not by inspection: tools/parity-audit/AvaloniaCapture boots
the real app headless against a seeded database. Before, mobile-portrait-3-storage
showed grey-on-grey; after, the chip is amber on both portrait and landscape and
matches desktop-2-loggeddata. Substituting a literal magenta proved the inline
value was the thing being overridden rather than the resource failing to resolve.

The desktop pane is untouched — it never used the chip class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every existing test of Sessions.Status starts from a database this build
migrated from nothing, where the column has existed since the file did. That is
the one case a real user never has. Theirs was written before the column
existed, and the question a greenfield test cannot ask is what their sessions
read as afterwards: Complete, or — if the default landed wrong or the migration
never ran — flagged INCOMPLETE IMPORT on a session that imported perfectly well
a year ago. A chip appearing on untouched data is a visible regression no test
on a fresh database can see, and this repo has been burned twice by
database-state bugs (#181, #196).

Three tests that start where the user starts. The old schema is built by the
app's own migrations stopped at the last one that shipped (IMigrator.Migrate to
20260415000000_AddSessionSampleCount) rather than by a hand-written CREATE TABLE
that could drift from it; rows go in by raw SQL naming only the columns that
existed then; and only then does the new code get the file, through
DatabaseMigrator.PrepareMigration / ApplyMigrations and
LoggingManager.LoadPersistedLoggingSessions — what startup actually calls.

  - a pre-existing session still reads Complete, shows no chip, and keeps its
    samples and count;
  - the migration is reported as pending, applied automatically, and then no
    longer pending;
  - the migration reverses without taking the sessions with it, which matters
    because SQLite cannot drop a column without rebuilding the table.

The first asserts the fixture is honest before it asserts anything else — that
the Status column really is absent — so a mistake leaving the database fully
migrated cannot make it pass for the wrong reason.

Behaviour confirmed by neutering: changing the migration's defaultValue from 0
to 2 fails exactly the first test (1 failed, 2 passed), the other two being
independent of the default. 275 total, was 272.

No production change: the upgrade path was already correct. This is the guard
that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

tylerkron commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor Author

The INCOMPLETE IMPORT chip, actually rendered

XAML bindings are not compile-checked here, so a green build says nothing about whether a new label reaches the screen. These come from tools/parity-audit/AvaloniaCapture booting the real app headless on macOS against a seeded throwaway DAQIFI_DATA_DIR holding two sessions with samples — SD Import 900 (Status = Complete) and SD Import 901 (Status = ImportFailed). The chip appears on 901 and not on 900, which is the behaviour, not just the binding.

Desktop session row (desktop-2-loggeddata):

Desktop Logged Data pane: two session rows, SD Import 900 with only a SAMPLES 48K chip, SD Import 901 with SAMPLES 1.0K plus an amber-outlined INCOMPLETE IMPORT chip

Mobile session card (mobile-portrait-3-storage):

Mobile portrait Logged Data list: SD Import 900 with date and SAMPLES chips only, SD Import 901 additionally carrying an amber-outlined INCOMPLETE IMPORT chip

What this caught

The mobile chip was reaching the screen but not reading as a warning — same muted grey as the neutral metadata pills next to it, and no border at all. Both causes were silent, and neither is visible to the compiler: Border.chip sets no BorderThickness and Border's default is 0, so the amber BorderBrush painted nothing; and Border.chip > TextBlock already sets Foreground, which the inline {DynamicResource StatusAmber} lost to.

Same chip, before and after the fix in c4736c0 (magnified 8×):

Mobile INCOMPLETE IMPORT chip before the fix: grey text on a grey pill with no border, indistinguishable from an ordinary metadata chip

Mobile INCOMPLETE IMPORT chip after the fix: amber text inside an amber border

Substituting a literal magenta proved the inline value was the thing being overridden rather than the resource failing to resolve. Landscape (mobile-landscape-3-storage) renders the same amber chip. The desktop pane never used the chip class and was correct throughout — its capture above is unchanged by the fix.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 527c46d

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green — ready for review on 527c46d: the migration path is safe (Sessions.Status rides the existing PrepareMigration/ApplyMigrations startup path, is NOT NULL DEFAULT 0 = Complete so no pre-existing session starts showing INCOMPLETE IMPORT after an upgrade, and reverses without losing rows — now pinned by three tests that build the shipped schema from the app's own migrations rather than a fresh database, and proven to bite by flipping the default to 2); and the new label was seen rendering on both heads via AvaloniaCapture against a seeded database — which is how the one real defect turned up, the mobile chip drawing as ordinary grey metadata because Border.chip sets no BorderThickness and its inline Foreground lost to Border.chip > TextBlock, fixed in c4736c0 and re-captured amber.

Qodo round 4 on this head: 0 bugs, 0 rule violations, 0 unresolved threads (5 total, all resolved), re-checked after a settle interval with no change. CI: all six checks pass, Android and iOS included. Not merged.

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.

fix(sd-import): a failed import leaves a half-session that looks complete, and an empty one is deleted at next launch

1 participant