Skip to content

fix(firmware): close shot-history file/header inheritance race across…#715

Open
FR4NKLN wants to merge 2 commits into
jniebuhr:masterfrom
FR4NKLN:fix/shot-history-race
Open

fix(firmware): close shot-history file/header inheritance race across…#715
FR4NKLN wants to merge 2 commits into
jniebuhr:masterfrom
FR4NKLN:fix/shot-history-race

Conversation

@FR4NKLN

@FR4NKLN FR4NKLN commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

When a user starts a second brew before the first brew's shot file has been finalized, ShotHistoryPlugin writes the new brew's samples into the previous brew's file under the previous brew's header. The user sees their most recent brew either missing from Shot History entirely, or labeled with the previous profile's name.

What was wrong

ShotHistoryPlugin::startRecording() did not reset isFileOpen or header. The cleanup branch in record() only runs on its 250 ms tick AND only when recording=false AND extendedRecording=false. With a BLE scale connected, extendedRecording can stay true for up to 3 seconds after controller:brew:end.

The race:

  1. Brew A ends. endRecording() flips recording=false, extendedRecording=true. Post-drip sampling continues. File still open with A's header.
  2. User starts Brew B. Controller::activate() fires controller:brew:clearendExtendedRecording() flips extendedRecording=false. Then delay(200) (shorter than the 250 ms record() interval, so the cleanup branch usually does NOT run). Then controller:brew:startstartRecording() flips recording=true.
  3. Next record() tick sees isFileOpen=true so skips the file-open path. Brew B's samples get appended to A's file under A's header.
  4. Cleanup eventually runs at end of B. Patches A's file header with B's sampleCount / durationMs. The index entry upsert at lines 622-628 overwrites the early-index entry (which had the correct "B" name) with the merged header.

Net effect: B's brew appears in Shot History under A's name and A's timestamp / id; B's separately-named .slog file is never created.

Compounded by a separate narrower race: record() reads getSelectedProfile() live on each tick to populate the header. If the user switches profile between brew:start firing and the first record() tick, the wrong name lands in the header even without the file-inheritance race.

Fix

Two coupled changes:

  1. Extract the cleanup branch into finalizeShotFile(), then call it defensively at the top of startRecording() when isFileOpen=true. The previous shot gets properly patched, closed, and indexed before the new shot's state is initialized.

  2. Populate the shot file header synchronously in startRecording() — magic / sample-size / startEpoch / profileId / profileName / phaseTransitionCount — using the running BrewProcess's per-shot immutable profile copy (brewProcess->profile). Closes the live-profile-read race. record()'s file-open path no longer initializes the header; startRecording() guarantees it.

The previously-dead currentProfileName field is repurposed as a mirror of the header value for downstream telemetry consistency.

User-visible impact

Before: rapid back-to-back brews (within ~3s with BLE scale connected, or ~250 ms without) caused the second brew to be lost or mislabeled in Shot History.

After: each brew gets its own correctly-named entry in Shot History regardless of how quickly the next brew starts.

Test plan

  • Run brew A with profile A (BLE scale connected). Let it finish.
  • Immediately start brew B with profile B (within a few seconds of A finishing, before A's extended recording would naturally complete).
  • Open Shot History — both A and B appear with correct profile names in correct chronological order.
  • Repeat without BLE scale connected — same expectation.
  • Standard "wait a while between brews" workflow still works (no regression on the normal case).

Summary by CodeRabbit

  • Bug Fixes
    • More reliable shot recording: previous incomplete recordings are finalized before new ones start and very short/failed shots are discarded.
  • Improvements
    • Profile details are captured at recording start for more accurate shot metadata and consistent history entries.

Review Change Stack

… rapid brews

When a brew ends, ShotHistoryPlugin's cleanup branch in record() only
runs on the next 250ms tick AND only if recording=false AND
extendedRecording=false. If the user starts a second brew before that
tick runs (or before extendedRecording's up-to-3s window closes), the
new brew's startRecording() flipped recording=true and left the
previous shot's file handle and header in place — the next record()
tick saw isFileOpen=true and appended the new brew's samples into the
old file under the old header, then upserted the index with the wrong
profileId / profileName. The new brew was effectively erased; the old
shot's true ending was lost.

Two changes:

1. Extract the cleanup branch in record() into finalizeShotFile(), then
   call it defensively at the top of startRecording() when isFileOpen.
   The previous shot now gets properly patched, closed, and indexed
   before the new shot's state is initialized.

2. Populate the shot file header (magic / sample-size / startEpoch /
   profileId / profileName / phaseTransitionCount) synchronously in
   startRecording() using the running BrewProcess's per-shot immutable
   profile copy, rather than re-reading the live selectedProfile in
   record() on the next tick. Closes a separate narrower race where a
   profile switch landing between brew:start and the first record()
   tick would stamp the wrong profile name into the header. Repurposes
   the existing currentProfileName field (previously set but unread)
   as a mirror of the header value for downstream telemetry.

record()'s file-open path no longer initializes the header — startRecording
guarantees it's already populated.
@cla-bot

cla-bot Bot commented May 27, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have yours on file. In order for us to review and merge your code, please contact @jniebuhr (mdwasp) on Discord to get yourself added.

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5af06de7-3068-4a93-b3f6-5a2e5e683d71

📥 Commits

Reviewing files that changed from the base of the PR and between 9dfba43 and 86b92f0.

📒 Files selected for processing (1)
  • src/display/plugins/ShotHistoryPlugin.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/display/plugins/ShotHistoryPlugin.cpp

📝 Walkthrough

Walkthrough

Shot recording lifecycle refactored: header initialization moved to startRecording(), end-of-shot work extracted into finalizeShotFile(), and startRecording() now finalizes any open shot file before starting a new one.

Changes

Shot Recording and Finalization

Layer / File(s) Summary
Finalization method extraction
src/display/plugins/ShotHistoryPlugin.h, src/display/plugins/ShotHistoryPlugin.cpp
New private method finalizeShotFile() consolidates shot-end workflow: flushes buffered samples, patches header with final values (sampleCount, durationMs, finalWeight), seeks and rewrites header, closes the file, discards short/failed shots (≤ 7.5s), and updates the shot history index (upsert, overwriting earlier entry if present).
Record method refactoring
src/display/plugins/ShotHistoryPlugin.cpp
record() no longer prepares the shot-log header on file-open and now delegates completion to finalizeShotFile(); it assumes header was populated at startRecording().
StartRecording defensive behavior and header population
src/display/plugins/ShotHistoryPlugin.cpp
startRecording() finalizes any previously open shot file before starting a new recording, synchronously populates the shot-log header from the active BrewProcess profile snapshot (capturing profile id/name and phase-transition state), and sets currentProfileName from the populated header.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • jniebuhr/gaggimate#491: Overlaps with changes to ShotHistoryPlugin's shot-finalization/cleanup flow.
  • jniebuhr/gaggimate#605: Related upsert/patching of shot-log headers and completed ShotIndexEntry persistence.
  • jniebuhr/gaggimate#322: Touches shot recording paths in ShotHistoryPlugin and relates to recorded sample/header fields.

Suggested labels

cla-signed

🐰 I hopped through code with a curious sniff,
wrote headers at start and closed each ruffled cliff.
Finalize tidy, no samples astray —
history kept neat at the end of the day.
A little rabbit clap for a safer playback riff!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title partially describes the main change—it references 'shot-history file/header inheritance race' which is central to the fix, but is truncated with 'across…' and doesn't fully convey the core issue being resolved.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…tory header

SonarQube flagged the two `strncpy` calls in `startRecording()` as
`cpp:S5816` ("strncpy may not null-terminate") security hotspots —
even though each was followed by an explicit NUL-termination write.
Switched to `snprintf`, which is bounded and always null-terminates
within the destination buffer in one call.

Observable behavior identical; clears the Quality Gate on this PR.
@cla-bot

cla-bot Bot commented May 28, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have yours on file. In order for us to review and merge your code, please contact @jniebuhr (mdwasp) on Discord to get yourself added.

@sonarqubecloud

Copy link
Copy Markdown

@jniebuhr

Copy link
Copy Markdown
Owner

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label May 28, 2026
@cla-bot

cla-bot Bot commented May 28, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

FR4NKLN added a commit to FR4NKLN/gaggimate that referenced this pull request May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants