Skip to content

fix(sync): refuse HISTORY_END trim when gate drops leave an empty buffer - #198

Open
Brackyt wants to merge 3 commits into
OpenStrap:mainfrom
Brackyt:fix/safe-trim-no-durable-progress
Open

fix(sync): refuse HISTORY_END trim when gate drops leave an empty buffer#198
Brackyt wants to merge 3 commits into
OpenStrap:mainfrom
Brackyt:fix/safe-trim-no-durable-progress

Conversation

@Brackyt

@Brackyt Brackyt commented Aug 5, 2026

Copy link
Copy Markdown

Summary

  • Refuse echoing a HISTORY_END trim token when the burst banked no durable rows (raws/archives empty) but the RecordGate dropped samples — stops permanently deleting unread 1 Hz flash after reconnect / bad gate windows.
  • After 3 consecutive refuses, defensive SET_CLOCK + bounce so the next session can re-admit the chunk.
  • lastTrimAdvanced only flips when durable rows were banked, so empty token ACKs no longer feed auto-continue while the frontier stays frozen.
  • Still allows archive-only, mixed banked+drops, and console-only empty (0 drops).

Observed on WHOOP 5 hardware as frozen rec_ts_hw / HR graph gaps while batches kept ACKing with unchanged record counts; the guard is band-agnostic sync safety (not gen5-specific).

Test plan

  • flutter test test/ble_safe_trim_test.dart test/sync_policy_test.dart (86 passed)
  • Soak on device: after BT toggle / reconnect, confirm HR continues and logs show NOT ACKing / no_durable_progress instead of empty drop-ACKs walking the cursor
  • Confirm archive-only and normal productive bursts still ACK/trim as before

Summary by CodeRabbit

  • Bug Fixes
    • Improved Bluetooth history synchronization when records are rejected or no durable progress is made.
    • Prevented empty or archive-only transfers from being incorrectly treated as completed history advancement.
    • Improved validation for mixed successful and rejected record batches.
    • Added recovery handling after repeated unsuccessful history acknowledgments, including clock resynchronization and reconnection.
    • Synchronization now reports when clock recovery ultimately fails.
    • Expanded synchronization diagnostics to report buffered archive records and persistence status.

Empty drop-only ACKs were advancing the strap cursor and auto-continue
while decoded_onehz stayed frozen, permanently deleting unread 1 Hz flash
after reconnect. Also require durable rows before lastTrimAdvanced.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 46eed030-92ac-441c-8bc1-cf6063c5e6fa

📥 Commits

Reviewing files that changed from the base of the PR and between 95d7c24 and e32a2f9.

📒 Files selected for processing (2)
  • lib/ble/ble_engine.dart
  • test/ble_safe_trim_test.dart

📝 Walkthrough

Walkthrough

Historical BLE trim handling now requires durable progress. Drop-only bursts refuse HISTORY_END, record diagnostics, and trigger clock correction with reconnection after repeated refusals. Commit tracking includes archives, and tests cover empty, archive-only, dropped, and failed commits.

Changes

BLE trim safety

Layer / File(s) Summary
Trim acknowledgement policy
lib/ble/ble_state.dart, test/ble_safe_trim_test.dart
TrimAckPolicy blocks ACKs for dropped bursts with no durable rows. Existing session, discard, and commit-failure checks remain in precedence.
Durable commit tracking
lib/ble/ble_engine.dart, test/ble_safe_trim_test.dart
DrainController.commit includes archives when determining trim advancement. Empty commits do not advance the cursor, while archive-only commits do. Safe-trim wiring now requires atomic commit support.
ACK refusal and recovery
lib/ble/ble_engine.dart, lib/sync/sync_policy.dart, test/no_durable_progress_escalation_test.dart
The engine records no-progress refusals, persists the refusal streak across reconnects, applies clock correction after the configured threshold, surfaces syncClockLost after repeated ineffective remedies, and resets the tracker after a successful trim ACK.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BLEEngine
  participant DrainController
  participant TrimAckPolicy
  participant NoDurableProgressEscalation
  BLEEngine->>DrainController: Commit historical burst
  DrainController-->>BLEEngine: Report durable rows and dropped records
  BLEEngine->>TrimAckPolicy: Evaluate trim observations
  TrimAckPolicy-->>BLEEngine: Allow or block HISTORY_END
  BLEEngine->>NoDurableProgressEscalation: Track refusal or successful ACK
  NoDurableProgressEscalation-->>BLEEngine: Request SET_CLOCK and reconnect
Loading

Possibly related PRs

Suggested labels: Review effort 5/5

Suggested reviewers: abdulsaheel, dannymcc, localhoop

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary safe-trim fix for bursts that contain dropped records and no durable rows.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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.

@Brackyt
Brackyt marked this pull request as ready for review August 5, 2026 19:04
Copilot AI lite review requested due to automatic review settings August 5, 2026 19:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Brackyt

Brackyt commented Aug 5, 2026

Copy link
Copy Markdown
Author

This removed gaps in my HR graph correctly

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 2244-2288: Add engine-level regression coverage around the
HISTORY_END handling for TrimAckVerdict.blockedNoDurableProgress: verify the
first two refusals neither ACK nor reconnect, the third invokes setClock and
tears down the session, and a successful ACK resets _noDurableTrimRefuseStreak
so subsequent refusals start from zero. Reuse the existing BLE engine test
harness and mocks for session lifecycle and command transmission.
- Around line 2403-2426: Track completion of records written through the
unbuffered path in DrainController.onHistoricalRecord, and await those writes
before the TrimAckPolicy.evaluate decision in the HistoryEnd flow so
hadDurableRows reflects persisted direct writes. Alternatively, route historical
drains through an awaited batch commit while preserving the no-progress gate for
rejected records. Add a regression test for a mixed burst with neither
onCommitBatch nor onRecordsBatch, covering the applicable raw decode and
session/export trigger paths.
- Around line 3426-3434: Update the fallback commit path used when onCommit is
null and onRecordsBatch is available so every archive in archives is persisted
through onArchive before reporting success; if no archive sink exists, fail the
commit and preserve the buffer so the trim token is not acknowledged. Keep raw
persistence unchanged, and add a regression covering an archive-only chunk
through this fallback path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ca9cd751-0aa7-4714-820c-a8bf1957de88

📥 Commits

Reviewing files that changed from the base of the PR and between d911f60 and e8ae524.

📒 Files selected for processing (3)
  • lib/ble/ble_engine.dart
  • lib/ble/ble_state.dart
  • test/ble_safe_trim_test.dart

Comment thread lib/ble/ble_engine.dart
Comment on lines +2244 to +2288
case TrimAckVerdict.blockedNoDurableProgress:
// Gate rejected every historical sample this burst and nothing was
// banked (no raws, no archives). Echoing the token would trim flash
// we never stored — the HR-gap / frozen-cursor failure mode after a
// reconnect with a bad plausibility window. Keep the chunk on the
// band; after a short streak, re-correlate the clock and bounce.
_noDurableTrimRefuseStreak++;
_log(
'[SYNC] HISTORY_END token=$tokenHex has no durable rows but the '
'plausibility gate dropped samples this burst — NOT ACKing '
'(streak=$_noDurableTrimRefuseStreak). The band keeps the chunk; '
'a SET_CLOCK/reconnect may clear a poisoned gate window.',
);
await _bestEffortLedgerWrite(() => LocalDb.upsertSyncLedgerEntry(
chunkId: 'batch:$tokenHex',
kind: 'historical_batch',
status: 'trim_refused',
lastError: 'no_durable_progress',
metaPatch: {
'batch_id': batchId,
'records': d.records,
'no_durable_refuse_streak': _noDurableTrimRefuseStreak,
},
));
if (_noDurableTrimRefuseStreak >= 3 && !_sessionIsStale(session)) {
_log(
'[SYNC] $_noDurableTrimRefuseStreak consecutive no-durable trim '
'refuses — defensive SET_CLOCK + bounce so the next session can '
're-admit the re-delivered chunk.',
);
_noDurableTrimRefuseStreak = 0;
try {
await setClock();
} catch (e) {
_log('[SYNC] defensive SET_CLOCK after no-durable refuse failed: $e');
}
if (!_sessionIsStale(session)) {
unawaited(
_teardownSession(intentional: false).then((_) {
_setPhase(BleConnState.idle);
}),
);
}
}
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add regression coverage for refusal recovery.

The changed tests cover TrimAckPolicy and DrainController, but not the new engine behavior after three no-durable-progress refusals. Add an engine-level regression that verifies the first two refusals do not ACK or reconnect, the third sends SET_CLOCK and tears down the session, and a successful ACK resets the streak.

As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ble/ble_engine.dart` around lines 2244 - 2288, Add engine-level
regression coverage around the HISTORY_END handling for
TrimAckVerdict.blockedNoDurableProgress: verify the first two refusals neither
ACK nor reconnect, the third invokes setClock and tears down the session, and a
successful ACK resets _noDurableTrimRefuseStreak so subsequent refusals start
from zero. Reuse the existing BLE engine test harness and mocks for session
lifecycle and command transmission.

Source: Coding guidelines

Comment thread lib/ble/ble_engine.dart
Comment thread lib/ble/ble_engine.dart
…ing silent

Refusing a HISTORY_END whose burst banked nothing is right, and this PR gets
that part right -- echoing the token lets the band delete flash we never
stored, the one irreversible act in this protocol. The problem is everything
AFTER the refusal.

PARTIALLY REFUTING the review note first: this branch DOES already have a
refusal counter and a SET_CLOCK+bounce remedy at >= 3. The claim of "no
counter, no eventual path" is out of date. What is real is that neither could
actually fire.

1. THE COUNTER COULD NEVER CLIMB. `_noDurableTrimRefuseStreak` was zeroed in
   the per-connection setup block -- but the streak's own remedy is a
   SET_CLOCK + BOUNCE, i.e. a reconnect, and the idle watchdog also tears the
   session down after a burst that banked nothing. Every cycle reset the
   count, so the ">= 3 then run the remedy" branch was unreachable in the
   field and the loop ran forever at streak 1. The engine already documents
   this exact distinction one block up: "marginal-radio + post-bond-loop are
   NOT reset here -- they count consecutive bad cycles across reconnects".
   This is the same class of counter and belongs on the same side of that line.

2. A REMEDY THAT KEEPS FAILING WAS INVISIBLE. `EmptySyncTracker` (which sets
   `syncClockLost`) and `StuckStrapDetector` are BOTH only evaluated inside
   `_onOffloadFinished`, and that needs a HISTORY_COMPLETE this loop never
   reaches, because the band keeps re-delivering the same un-trimmed chunk. So
   a band with a lost RTC -- every record below the plausibility floor -- sat
   in refuse -> bounce -> refuse indefinitely behind a debug log, with no user
   signal at all.

Both now live in `NoDurableProgressEscalation` (lib/sync/sync_policy.dart),
pure and unit-testable, alongside BondRefusalGiveUp which it deliberately
mirrors -- the engine wires, the policy decides. Two thresholds, because there
are two questions: N consecutive refusals => try the remedy; M remedies that
did NOT work => surface `syncClockLost`. Counting spans reconnects and clears
only on a successful trim ACK, which is the only thing that proves the
condition is actually over.

It still NEVER ACKs. "Keep the data" stays the answer -- what changes is that a
persistently failing remedy becomes visible instead of silent.

7 tests, mutation-verified. Suite 1208 passing; the 6 failures in
notification_dedupe_test are pre-existing and reproduce on origin/main
unmodified.
@abdulsaheel

Copy link
Copy Markdown
Collaborator

Reviewed and pushed 95d7c24. The core judgement in this PR is right — refusing a HISTORY_END whose burst banked nothing is correct, because echoing the token lets the band delete flash we never stored, which is the one irreversible act in this protocol. My changes are all downstream of that decision.

First, partly refuting the review note

The finding I was handed said "no counter, no age bound, no eventual path". That's out of date — this branch already has a refusal counter and a SET_CLOCK+bounce remedy at >= 3. Credit where it's due.

What's real is that neither could actually fire.

1. The counter could never climb

_noDurableTrimRefuseStreak was zeroed in the per-connection setup block (ble_engine.dart:1167). But the streak's own remedy is a SET_CLOCK + bounce — a reconnect — and the idle watchdog also tears the session down after a burst that banked nothing.

So every cycle reset the count. The >= 3 branch was unreachable in the field; the loop ran forever at streak 1.

The engine already documents this exact distinction one block up:

// Marginal-radio + post-bond-loop are NOT reset here — they count consecutive bad cycles across reconnects and self-reset on a healthy disconnect.

This is the same class of counter and belongs on the same side of that line.

2. A remedy that keeps failing was invisible

EmptySyncTracker (which sets syncClockLost) and StuckStrapDetector are both only evaluated inside _onOffloadFinished — and that needs a HISTORY_COMPLETE this loop never reaches, because the band keeps re-delivering the same un-trimmed chunk.

So a band with a lost RTC — every record below the plausibility floor — sat in refuse → bounce → refuse indefinitely behind a debug log, with no user-facing signal at all. That's the part I'd most want fixed before this ships.

What I did

Both concerns now live in NoDurableProgressEscalation (lib/sync/sync_policy.dart), pure and unit-testable, deliberately mirroring BondRefusalGiveUp right above it — engine wires, policy decides.

Two thresholds, because there are two different questions:

threshold question
N consecutive refusals should we try the remedy (SET_CLOCK + bounce)?
M remedies that didn't work should we stop assuming the next one will, and tell the user?

Counting spans reconnects and clears only on a successful trim ACK — the one event that actually proves the condition is over.

It still never ACKs. "Keep the data" stays the answer. What changes is that a persistently failing remedy becomes visible instead of silent.

Verification

7 tests, mutation-verified — breaking the remedy counter fails exactly the four tests that depend on it. Suite 1208 passing / 6 failing; the 6 are notification_dedupe_test and reproduce on origin/main unmodified, so they're pre-existing and unrelated (worth its own issue — I've hit them on four branches now).

@Brackyt

Brackyt commented Aug 6, 2026

Copy link
Copy Markdown
Author

Nice find too

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Checked @coderabbitai's three findings against the code. One is a real safe-trim hole, one is real but latent, one my refactor partly answers. Reachability analysis for each, since that's what decides whether they block.

1. "Persist archive-only fallback commits before reporting success" — REAL, and the serious one

Confirmed in _DrainController.commit():

if (onCommit != null) {
  await onCommit!(raws, samples, tokenHex, archives: archives);
} else if (onRecordsBatch != null && raws.isNotEmpty) {
  await onRecordsBatch!(raws, samples);
}
return true;

With onCommit == null and an archive-only chunk (raws empty, archives non-empty), neither branch runs — yet it returns true. Meanwhile hadDurable = raws.isNotEmpty || archives.isNotEmpty is true, so TrimAckPolicy returns send, the token is echoed, and the band trims flash for archive records that were never persisted. _archives.clear() already ran, so they're gone from the buffer too.

That's a direct violation of the invariant this PR exists to defend.

Reachability: latent, not live. Both production constructions set all three sinks (app_state.dart:667–675, background_sync.dart:67–72). AppState.forTesting sets neither batch sink, and no path sets onRecordsBatch without onCommitBatch. So it needs a bespoke construction to hit today.

I have not fixed it, deliberately. The obvious fix — refuse to report success when archives have no sink — converts silent data loss into a permanent refusal loop for that configuration, because archives can never be persisted there. That's a design question about whether the configuration should exist at all, and it's your drain controller. Options as I see them: drop the onRecordsBatch-only path, route archives through a sink that exists in it, or assert at construction that archives imply onCommit. Happy to implement whichever you prefer.

2. "Track durable progress for the unbuffered record path" — real, same latency

In the both-sinks-null configuration, onHistoricalRecord sends accepted records through unawaited(onRecord(...)) rather than buffering, so bufferedRecords stays 0 and hadDurableRows is false even though records were persisted. A burst with one admitted and one gate-rejected record then gets blockedNoDurableProgress incorrectly.

Also only reachable via AppState.forTesting today. But worth flagging that my change makes this louder, not quieter: false refusals now accumulate across reconnects and eventually surface syncClockLost to the user. If that configuration ever becomes real, the failure mode is a user-visible false alarm rather than a silent one. Argues for fixing the accounting rather than relying on config luck.

3. "Add regression coverage for refusal recovery" — partly answered

I moved the escalation out of the engine into NoDurableProgressEscalation precisely so it could be tested, and it now has 7 mutation-verified tests covering exactly what CodeRabbit asked for: the first N refusals don't trigger the remedy, the Nth does, and a successful ACK resets. What's still missing is the engine-level wiring test (that the remedy really sends SET_CLOCK and tears down). That needs a BLE engine harness; I'd take it as a follow-up rather than a blocker, since the decision logic — the part that was actually broken — is now covered directly.


Worth noting these three are all independent of the bug I fixed, which was that the counter was zeroed per-connection while its own remedy is a reconnect. That one's live, not latent.

@Brackyt — you mentioned this "removed gaps in my HR graph correctly", which is good confirmation the core gate is doing its job.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Candidate files =='
fd -t f -a 'ble_engine.dart|app_state.dart|background_sync.dart|no_durable_progress_escalation_test.dart|ble_safe_trim_test.dart' . | sort

echo
echo '== BLE engine outline =='
ast-grep outline lib/ble/ble_engine.dart --items all --type class,function --match 'Drain|Historical|commit|onHistoricalRecord' || true

echo
echo '== Drain controller and historical-record contexts =='
rg -n -A45 -B20 \
  'class _?DrainController\b|Future<bool>\s+commit\s*\(|Future<void>\s+commit\s*\(|onHistoricalRecord\s*\(' \
  lib/ble/ble_engine.dart

echo
echo '== Sink construction contexts =='
rg -n -A20 -B12 \
  'onCommit|onRecordsBatch|onCommitBatch|onHistoricalRecord' \
  lib/app_state.dart lib/background_sync.dart 2>/dev/null || true

echo
echo '== Relevant test names and assertions =='
rg -n -A8 -B3 \
  'NoDurableProgressEscalation|archive|unbuffered|trimAcked|trimRefused|SET_CLOCK|disconnect|bounce' \
  test/no_durable_progress_escalation_test.dart test/ble_safe_trim_test.dart 2>/dev/null || true

Length of output: 36047


@abdulsaheel: Your analysis is correct.

DrainController.commit() confirms the archive-only hole. It clears _archives, skips both sink calls when onCommit == null, and returns true. The controller must not report a durable commit in that state.

I recommend that DrainController require onCommit for every buffered historical-drain configuration. The constructor should reject onRecordsBatch without onCommit. The commit() method should also defensively return false and restore the snapshot if it receives buffered archives without onCommit. The constructor check prevents invalid production wiring. The runtime check preserves the safe-trim invariant if a future caller bypasses that contract.

The unbuffered path needs more than hadDurableRows accounting. onHistoricalRecord() calls unawaited(onRecord(sample, raw)). Therefore, commit() cannot prove that the record write completed before it permits an ACK. This remains test-only under the stated wiring, but it is not safe as a general trim path. Treat the no-batch-sink mode as non-trimmable, or redesign it so commit() awaits durable writes and records their result. The same rule applies to onUndecodableRecord() when onArchive is null.

The NoDurableProgressEscalation tests cover the policy behavior that the original finding requested. They verify the threshold, reconnect-spanning remedy count, escalation latch, and ACK reset. An engine-level test for SET_CLOCK plus disconnect remains valuable integration coverage. It is not required to validate the corrected policy logic in this PR.

I could not independently verify the production construction sites in this pass because the shell search used paths that do not match the repository layout. That does not change the verified controller defects.


✏️ Learnings added
Learnt from: abdulsaheel
URL: https://github.com/OpenStrap/edge/pull/198

Timestamp: 2026-08-06T19:40:00.400Z
Learning: In the Flutter BLE historical-offload flow, `DrainController.commit()` must report success only after every buffered raw record and `ArchiveRecord` is durably persisted. The `onRecordsBatch` fallback does not accept archives or the trim token, so a configuration with `onRecordsBatch` and no `onCommit` must not permit a successful safe-trim commit.

Learnt from: abdulsaheel
URL: https://github.com/OpenStrap/edge/pull/198

Timestamp: 2026-08-06T19:40:00.400Z
Learning: In `lib/ble/ble_engine.dart`, the unbuffered `DrainController.onHistoricalRecord()` path invokes `onRecord(sample, raw)` with `unawaited`. This path cannot establish durable persistence before a `HISTORY_END` trim ACK unless commit-time logic awaits and verifies those writes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Correction to my earlier note on this PR, and it's good news.

I described the 6 notification_dedupe_test failures as "pre-existing, reproduce on origin/main unmodified" and suggested they deserved their own issue. The first half was right; the framing was wrong, and I've now root-caused them.

They are a time bomb, not a standing breakage. The suite builds date-prefixed dedupe keys from a hardcoded 2026-07-23, and FiredKeyStore prunes dated flags older than retentionDays (14). While that date was recent the keys stayed inside the window; once it aged past 14 days, every key was pruned the instant it was written, so repeat emits fired again:

Expected: <1>   Actual: <3>

main passed CI on 2026-08-04 when the date was 12 days old, and has been failing since the window closed — same commit, no code change.

Proved it by substituting today's date into the unmodified file on origin/main: all 15 turn green. Fixed in #207 (test-only, no lib/ change); the full suite is 1201 passing, 0 failing with it.

Practical impact here: this PR's CI cannot go green until #207 merges, regardless of its own content. Sorry for the noise — "pre-existing and unrelated" was accurate but undersold that it was actively blocking you.

…nbuffered drains

Reject onRecordsBatch without onCommit at construction, buffer only when
onCommit is wired, and refuse HISTORY_END when supportsSafeTrim is false so
archive-only or fire-and-forget paths cannot ACK flash that was never banked.
@Brackyt

Brackyt commented Aug 6, 2026

Copy link
Copy Markdown
Author

DrainController safe-trim hardening landed (e32a2f9)

Addresses the archive-only / unbuffered holes from review:

  1. Constructor rejects onRecordsBatch without onCommit (ArgumentError).
  2. supportsSafeTrim is onCommit != null only; HISTORY_END refuses trim when false (band keeps the chunk).
  3. _buffering only when onCommit is wired — unbuffered fire-and-forget no longer looks banked for trim.
  4. commit() fails closed (StateErrorfalse, re-buffer) if buffered content / token arrives without onCommit (defense in depth).

Regression group in test/ble_safe_trim_test.dart (P0 — DrainController wiring must not allow latent safe-trim holes).

@abdulsaheel

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.

3 participants