Skip to main content

fsqlite_wal/
wal.rs

1//! Core WAL file I/O layer.
2//!
3//! Provides [`WalFile`], a VFS-backed abstraction over the SQLite WAL file format.
4//! Handles WAL creation, frame append with rolling checksum chain, frame reads,
5//! validation, and reset for checkpoint.
6//!
7//! The on-disk layout is:
8//! ```text
9//! [WAL Header: 32 bytes]
10//! [Frame 0: 24-byte header + page_size bytes]
11//! [Frame 1: 24-byte header + page_size bytes]
12//! ...
13//! [Frame N: 24-byte header + page_size bytes]
14//! ```
15
16use fsqlite_error::{FrankenError, Result};
17use fsqlite_types::cx::Cx;
18use fsqlite_types::flags::SyncFlags;
19use fsqlite_vfs::{SyncKind, VfsFile, VfsWriteCompletion};
20use tracing::{debug, error, warn};
21
22/// Whether the `FRANKENSQLITE_PARANOID_DURABILITY` env var is set.
23/// Checked once at startup to avoid repeated `env::var` calls on the hot path.
24static PARANOID_DURABILITY: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
25    std::env::var("FRANKENSQLITE_PARANOID_DURABILITY")
26        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
27});
28
29use crate::checksum::{
30    SqliteWalChecksum, WAL_FORMAT_VERSION, WAL_FRAME_HEADER_SIZE, WAL_HEADER_SIZE, WAL_MAGIC_LE,
31    WalChecksumTransform, WalFrameHeader, WalHeader, WalSalts, compute_wal_frame_checksum,
32    read_wal_header_checksum, wal_header_checksum, write_wal_frame_checksum,
33    write_wal_frame_checksum_fields, write_wal_frame_salts,
34};
35
36#[inline]
37fn log_replay_decision(
38    replay_cursor: &'static str,
39    frame_no: usize,
40    commit_boundary: usize,
41    decision_reason: &'static str,
42) {
43    debug!(
44        replay_cursor,
45        frame_no, commit_boundary, decision_reason, "WAL replay decision"
46    );
47}
48
49struct VfsWritePreflight<'a> {
50    completion: Option<&'a VfsWriteCompletion>,
51}
52
53impl<'a> VfsWritePreflight<'a> {
54    fn new(completion: Option<&'a VfsWriteCompletion>) -> Self {
55        Self { completion }
56    }
57
58    fn hand_off(&mut self) {
59        self.completion = None;
60    }
61}
62
63impl Drop for VfsWritePreflight<'_> {
64    fn drop(&mut self) {
65        if let Some(completion) = self.completion {
66            completion.complete_error();
67        }
68    }
69}
70
71/// Borrowed frame descriptor used for consolidated WAL writes.
72#[derive(Debug, Clone, Copy)]
73pub struct WalAppendFrameRef<'a> {
74    /// Database page number this frame writes.
75    pub page_number: u32,
76    /// Page contents for the frame. Must be exactly `page_size` bytes.
77    pub page_data: &'a [u8],
78    /// Database size in pages for commit frames, or 0 for non-commit frames.
79    pub db_size_if_commit: u32,
80}
81
82/// Identity for one WAL generation.
83///
84/// A generation changes whenever the WAL header is reset for a new checkpoint
85/// epoch. Salts usually change too, but correctness must not rely on that:
86/// reset/ABA detection must still work if a caller reuses the same salt pair
87/// with a new checkpoint sequence.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct WalGenerationIdentity {
90    /// Checkpoint sequence stored in the WAL header.
91    pub checkpoint_seq: u32,
92    /// Salt pair copied into WAL frames for this generation.
93    pub salts: WalSalts,
94}
95
96impl WalGenerationIdentity {
97    /// Build a generation identity from a parsed WAL header.
98    #[must_use]
99    pub const fn from_header(header: &WalHeader) -> Self {
100        Self {
101            checkpoint_seq: header.checkpoint_seq,
102            salts: header.salts,
103        }
104    }
105}
106
107#[inline]
108fn push_wal_frame_bytes(
109    frame_scratch: &mut Vec<u8>,
110    page_number: u32,
111    db_size_if_commit: u32,
112    salts: WalSalts,
113    page_data: &[u8],
114) {
115    frame_scratch.extend_from_slice(&page_number.to_be_bytes());
116    frame_scratch.extend_from_slice(&db_size_if_commit.to_be_bytes());
117    frame_scratch.extend_from_slice(&salts.salt1.to_be_bytes());
118    frame_scratch.extend_from_slice(&salts.salt2.to_be_bytes());
119    frame_scratch.extend_from_slice(&[0_u8; 8]);
120    frame_scratch.extend_from_slice(page_data);
121}
122
123/// A WAL file backed by a VFS file handle.
124///
125/// Manages the write-ahead log: creation, sequential frame append with
126/// checksum chain integrity, frame reads, and reset after checkpoint.
127pub struct WalFile<F: VfsFile> {
128    file: F,
129    page_size: usize,
130    big_endian_checksum: bool,
131    header: WalHeader,
132    /// Rolling checksum from the last written/validated frame (or header if empty).
133    running_checksum: SqliteWalChecksum,
134    /// Number of valid frames currently in the WAL.
135    frame_count: usize,
136    /// Index of the latest visible commit frame for the active generation.
137    last_commit_frame: Option<usize>,
138    /// Reusable contiguous scratch for direct append paths.
139    ///
140    /// Ownership is per-`WalFile` handle. Append methods already require
141    /// `&mut self`, so reuse stays serialized per handle without reintroducing
142    /// any cross-writer coordination.
143    frame_scratch: Vec<u8>,
144    /// Frame count covered by the last successful WAL sync.
145    /// Used by debug-assertions and FRANKENSQLITE_PARANOID_DURABILITY to
146    /// verify the two-phase commit invariant: fsync must complete before
147    /// any CommitIndex publish for the same frames.
148    last_fsynced_frame_count: usize,
149}
150
151impl<F: VfsFile> WalFile<F> {
152    /// Re-synchronize this handle with the on-disk WAL if another writer has
153    /// appended frames or reset/truncated the file.
154    ///
155    /// This keeps `frame_count` and `running_checksum` coherent across
156    /// multiple concurrently-open `WalFile` handles.
157    pub async fn refresh(&mut self, cx: &Cx) -> Result<()> {
158        let frame_size = self.frame_size();
159        let expected_size = u64::try_from(WAL_HEADER_SIZE)
160            .expect("WAL header size fits u64")
161            .saturating_add(
162                u64::try_from(self.frame_count)
163                    .unwrap_or(u64::MAX)
164                    .saturating_mul(u64::try_from(frame_size).unwrap_or(u64::MAX)),
165            );
166        let file_size = self.file.file_size(cx)?;
167
168        // If file shrank (checkpoint reset/truncate, external compaction, etc.),
169        // or changed in a way we cannot safely reason about incrementally,
170        // rebuild state from the on-disk WAL from scratch.
171        if file_size < expected_size {
172            log_replay_decision("refresh", 0, self.frame_count, "file_shrank_rebuild");
173            return self.rebuild_state_from_file(cx).await;
174        }
175
176        // Validate current on-disk header and confirm it matches our view.
177        // This is necessary even if file_size == expected_size to detect ABA
178        // where the WAL was reset and then appended back to the exact same size.
179        let mut header_buf = [0u8; WAL_HEADER_SIZE];
180        let header_read = self.file.read(cx, &mut header_buf, 0).await?;
181        if header_read < WAL_HEADER_SIZE {
182            log_replay_decision("refresh", 0, self.frame_count, "header_short_read_corrupt");
183            return Err(FrankenError::WalCorrupt {
184                detail: format!(
185                    "WAL file too small for header during refresh: read {header_read}, need {WAL_HEADER_SIZE}"
186                ),
187            });
188        }
189
190        let disk_header = WalHeader::from_bytes(&header_buf)?;
191        let disk_big_endian = disk_header.big_endian_checksum();
192        let disk_header_checksum = read_wal_header_checksum(&header_buf)?;
193        let expected_header_checksum = wal_header_checksum(&header_buf, disk_big_endian)?;
194        if disk_header_checksum != expected_header_checksum {
195            log_replay_decision(
196                "refresh",
197                0,
198                self.frame_count,
199                "header_checksum_mismatch_corrupt",
200            );
201            return Err(FrankenError::WalCorrupt {
202                detail: "WAL header checksum mismatch during refresh".to_owned(),
203            });
204        }
205
206        // Header changed under us (e.g., RESET/TRUNCATE checkpoint) — rebuild.
207        if disk_header.magic != self.header.magic
208            || disk_header.format_version != self.header.format_version
209            || disk_header.page_size != self.header.page_size
210            || disk_header.checkpoint_seq != self.header.checkpoint_seq
211            || disk_header.salts != self.header.salts
212        {
213            log_replay_decision(
214                "refresh",
215                0,
216                self.frame_count,
217                "header_generation_changed_rebuild",
218            );
219            return self.rebuild_state_from_file(cx).await;
220        }
221
222        if file_size == expected_size {
223            return Ok(());
224        }
225
226        // Incrementally absorb newly appended complete frames.
227        //
228        // For live multi-connection operation we only need:
229        // - the new valid prefix length (`frame_count`)
230        // - the checksum seed for the next append (`running_checksum`)
231        //
232        // SQLite WAL frame headers already carry the post-frame rolling
233        // checksum, so we can ingest appended frames by reading headers only.
234        // Full checksum-chain verification is still performed on open/rebuild.
235        let frame_size_u64 = u64::try_from(frame_size).unwrap_or(u64::MAX);
236        let available_frames = usize::try_from(
237            file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0)) / frame_size_u64,
238        )
239        .unwrap_or(usize::MAX);
240        if available_frames <= self.frame_count {
241            return Ok(());
242        }
243
244        let mut new_frame_count = self.frame_count;
245        let mut new_running_checksum = self.running_checksum;
246        let mut last_commit_count = self.frame_count;
247        let mut last_commit_checksum = self.running_checksum;
248
249        let mut frame_buf = vec![0u8; frame_size];
250        for frame_index in self.frame_count..available_frames {
251            let frame_no = frame_index.saturating_add(1);
252            let offset = self.frame_offset(frame_index);
253            let bytes_read = self.file.read(cx, &mut frame_buf, offset).await?;
254            if bytes_read < frame_size {
255                log_replay_decision(
256                    "refresh_incremental",
257                    frame_no,
258                    last_commit_count,
259                    "truncated_tail_stop",
260                );
261                break; // Partial/torn tail frame; keep prior valid prefix.
262            }
263
264            let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
265            if frame_header.salts != self.header.salts {
266                log_replay_decision(
267                    "refresh_incremental",
268                    frame_no,
269                    last_commit_count,
270                    "salt_mismatch_stop",
271                );
272                break; // End of valid chain for this generation.
273            }
274
275            let expected = compute_wal_frame_checksum(
276                &frame_buf,
277                self.page_size,
278                new_running_checksum,
279                self.big_endian_checksum,
280            )?;
281            if frame_header.checksum != expected {
282                log_replay_decision(
283                    "refresh_incremental",
284                    frame_no,
285                    last_commit_count,
286                    "checksum_mismatch_stop",
287                );
288                break; // Checksum mismatch
289            }
290
291            new_running_checksum = expected;
292            new_frame_count += 1;
293
294            if frame_header.is_commit() {
295                last_commit_count = new_frame_count;
296                last_commit_checksum = new_running_checksum;
297                log_replay_decision(
298                    "refresh_incremental",
299                    frame_no,
300                    last_commit_count,
301                    "accept_commit",
302                );
303            } else {
304                log_replay_decision(
305                    "refresh_incremental",
306                    frame_no,
307                    last_commit_count,
308                    "accept_non_commit",
309                );
310            }
311        }
312
313        self.frame_count = last_commit_count;
314        self.running_checksum = last_commit_checksum;
315        self.last_commit_frame = last_commit_count.checked_sub(1);
316
317        Ok(())
318    }
319
320    async fn rebuild_state_from_file(&mut self, cx: &Cx) -> Result<()> {
321        // A rebuild is entered only when the old in-memory view no longer
322        // describes this file (generation change or shrink). Its prior sync
323        // watermark cannot authorize frames in the rebuilt view, so clear it
324        // before any fallible I/O and fail closed if rebuilding fails.
325        self.last_fsynced_frame_count = 0;
326
327        let mut header_buf = [0u8; WAL_HEADER_SIZE];
328        let header_read = self.file.read(cx, &mut header_buf, 0).await?;
329        if header_read < WAL_HEADER_SIZE {
330            log_replay_decision("rebuild", 0, self.frame_count, "header_short_read_corrupt");
331            return Err(FrankenError::WalCorrupt {
332                detail: format!(
333                    "WAL file too small for header during rebuild: read {header_read}, need {WAL_HEADER_SIZE}"
334                ),
335            });
336        }
337
338        let header = WalHeader::from_bytes(&header_buf)?;
339        let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
340        let big_endian_checksum = header.big_endian_checksum();
341        let header_checksum = read_wal_header_checksum(&header_buf)?;
342        let expected_header_checksum = wal_header_checksum(&header_buf, big_endian_checksum)?;
343        if header_checksum != expected_header_checksum {
344            log_replay_decision(
345                "rebuild",
346                0,
347                self.frame_count,
348                "header_checksum_mismatch_corrupt",
349            );
350            return Err(FrankenError::WalCorrupt {
351                detail: "WAL header checksum mismatch during rebuild".to_owned(),
352            });
353        }
354
355        self.header = header;
356        self.page_size = page_size;
357        self.big_endian_checksum = big_endian_checksum;
358        self.running_checksum = header_checksum;
359        self.frame_count = 0;
360
361        let mut new_frame_count = 0;
362        let mut new_running_checksum = header_checksum;
363        let mut last_commit_count = 0;
364        let mut last_commit_checksum = header_checksum;
365
366        let frame_size = self.frame_size();
367        let file_size = self.file.file_size(cx)?;
368        let max_frames = usize::try_from(
369            file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0))
370                / u64::try_from(frame_size).unwrap_or(1),
371        )
372        .unwrap_or(usize::MAX);
373
374        let mut frame_buf = vec![0u8; frame_size];
375        for frame_index in 0..max_frames {
376            let frame_no = frame_index.saturating_add(1);
377            let offset = self.frame_offset(frame_index);
378            let bytes_read = self.file.read(cx, &mut frame_buf, offset).await?;
379            if bytes_read < frame_size {
380                log_replay_decision(
381                    "rebuild",
382                    frame_no,
383                    last_commit_count,
384                    "truncated_tail_stop",
385                );
386                break;
387            }
388
389            let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
390            if frame_header.salts != self.header.salts {
391                log_replay_decision("rebuild", frame_no, last_commit_count, "salt_mismatch_stop");
392                break;
393            }
394
395            let expected = compute_wal_frame_checksum(
396                &frame_buf,
397                self.page_size,
398                new_running_checksum,
399                self.big_endian_checksum,
400            )?;
401            if frame_header.checksum != expected {
402                log_replay_decision(
403                    "rebuild",
404                    frame_no,
405                    last_commit_count,
406                    "checksum_mismatch_stop",
407                );
408                break;
409            }
410
411            new_running_checksum = expected;
412            new_frame_count += 1;
413
414            if frame_header.is_commit() {
415                last_commit_count = new_frame_count;
416                last_commit_checksum = new_running_checksum;
417                log_replay_decision("rebuild", frame_no, last_commit_count, "accept_commit");
418            } else {
419                log_replay_decision("rebuild", frame_no, last_commit_count, "accept_non_commit");
420            }
421        }
422
423        self.frame_count = last_commit_count;
424        self.running_checksum = last_commit_checksum;
425        self.last_commit_frame = last_commit_count.checked_sub(1);
426
427        Ok(())
428    }
429
430    /// Size in bytes of a single frame (header + page data).
431    #[must_use]
432    pub fn frame_size(&self) -> usize {
433        WAL_FRAME_HEADER_SIZE + self.page_size
434    }
435
436    /// Byte offset of frame `index` (0-based) within the WAL file.
437    #[allow(clippy::cast_possible_truncation)]
438    pub(crate) fn frame_offset(&self, index: usize) -> u64 {
439        // Compute in u64 to prevent usize overflow on 32-bit targets.
440        // WAL_HEADER_SIZE is 32.
441        let header_size = WAL_HEADER_SIZE as u64;
442        let idx = index as u64;
443        let frame_sz = self.frame_size() as u64;
444        header_size.saturating_add(idx.saturating_mul(frame_sz))
445    }
446
447    /// Number of valid frames in the WAL.
448    #[must_use]
449    pub fn frame_count(&self) -> usize {
450        self.frame_count
451    }
452
453    /// The parsed WAL header.
454    #[must_use]
455    pub fn header(&self) -> &WalHeader {
456        &self.header
457    }
458
459    /// The current WAL generation identity (`checkpoint_seq` + salts).
460    #[must_use]
461    pub fn generation_identity(&self) -> WalGenerationIdentity {
462        WalGenerationIdentity::from_header(&self.header)
463    }
464
465    /// Database page size in bytes.
466    #[must_use]
467    pub fn page_size(&self) -> usize {
468        self.page_size
469    }
470
471    /// Whether the WAL uses big-endian checksum words.
472    #[must_use]
473    pub fn big_endian_checksum(&self) -> bool {
474        self.big_endian_checksum
475    }
476
477    /// The current rolling checksum (after the last valid frame, or header seed).
478    #[must_use]
479    pub fn running_checksum(&self) -> SqliteWalChecksum {
480        self.running_checksum
481    }
482
483    #[cfg(test)]
484    #[must_use]
485    fn frame_scratch_len(&self) -> usize {
486        self.frame_scratch.len()
487    }
488
489    #[cfg(test)]
490    #[must_use]
491    fn frame_scratch_capacity(&self) -> usize {
492        self.frame_scratch.capacity()
493    }
494
495    #[cfg(test)]
496    #[must_use]
497    fn frame_scratch_ptr(&self) -> *const u8 {
498        self.frame_scratch.as_ptr()
499    }
500
501    /// Create a new WAL file, writing the 32-byte header.
502    ///
503    /// The file should already be opened via the VFS. This overwrites any
504    /// existing content by writing the header at offset 0 and truncating.
505    pub async fn create(
506        cx: &Cx,
507        mut file: F,
508        page_size: u32,
509        checkpoint_seq: u32,
510        salts: WalSalts,
511    ) -> Result<Self> {
512        let header = WalHeader {
513            magic: WAL_MAGIC_LE,
514            format_version: WAL_FORMAT_VERSION,
515            page_size,
516            checkpoint_seq,
517            salts,
518            checksum: SqliteWalChecksum::default(), // computed by to_bytes()
519        };
520        let header_bytes = header.to_bytes()?;
521        file.write(cx, &header_bytes, 0).await?;
522        file.truncate(
523            cx,
524            u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
525        )?;
526        // Make the fresh header + truncation durable before any frame is
527        // appended. Without this barrier a crash can leave the previous WAL
528        // generation's frames on disk behind a header they could
529        // chain-validate against, replaying stale frames as committed on the
530        // next open. (Salts are randomized per generation — GH #201 — which
531        // independently defends against stale-frame replay, but the barrier
532        // remains the primary ordering guarantee.)
533        file.sync(cx, SyncFlags::NORMAL)?;
534
535        let running_checksum = read_wal_header_checksum(&header_bytes)?;
536
537        debug!(
538            page_size,
539            checkpoint_seq,
540            salt1 = header.salts.salt1,
541            salt2 = header.salts.salt2,
542            "WAL file created"
543        );
544        crate::metrics::GLOBAL_WAL_METRICS.set_wal_frames_current(0);
545
546        Ok(Self {
547            file,
548            page_size: usize::try_from(page_size).expect("page size fits usize"),
549            big_endian_checksum: false,
550            header,
551            running_checksum,
552            frame_count: 0,
553            last_commit_frame: None,
554            frame_scratch: Vec::new(),
555            last_fsynced_frame_count: 0,
556        })
557    }
558
559    /// Open an existing WAL file by reading and validating its header,
560    /// then scanning frames to determine the valid frame count and
561    /// running checksum.
562    #[allow(clippy::too_many_lines)]
563    pub async fn open(cx: &Cx, file: F) -> Result<Self> {
564        // Read and parse the 32-byte header.
565        let mut header_buf = [0u8; WAL_HEADER_SIZE];
566        let bytes_read = file.read(cx, &mut header_buf, 0).await?;
567        if bytes_read < WAL_HEADER_SIZE {
568            log_replay_decision("startup_open", 0, 0, "header_short_read_corrupt");
569            return Err(FrankenError::WalCorrupt {
570                detail: format!(
571                    "WAL file too small for header: read {bytes_read}, need {WAL_HEADER_SIZE}"
572                ),
573            });
574        }
575        let header = WalHeader::from_bytes(&header_buf)?;
576        let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
577        let big_endian_checksum = header.big_endian_checksum();
578        let frame_size = WAL_FRAME_HEADER_SIZE + page_size;
579
580        // Validate header checksum.
581        let header_checksum = read_wal_header_checksum(&header_buf)?;
582        let expected_checksum =
583            crate::checksum::wal_header_checksum(&header_buf, big_endian_checksum)?;
584        if header_checksum != expected_checksum {
585            error!("WAL header checksum mismatch — file may be corrupt");
586            log_replay_decision("startup_open", 0, 0, "header_checksum_mismatch_corrupt");
587            return Err(FrankenError::WalCorrupt {
588                detail: "WAL header checksum mismatch".to_owned(),
589            });
590        }
591
592        // Scan frames to determine valid count and running checksum.
593        let file_size = file.file_size(cx)?;
594        let data_bytes =
595            file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"));
596        let max_frames = usize::try_from(data_bytes / u64::try_from(frame_size).unwrap_or(1))
597            .unwrap_or(usize::MAX);
598
599        let mut running_checksum = header_checksum;
600        let mut valid_frames = 0_usize;
601        let mut last_commit_frames = 0_usize;
602        let mut last_commit_checksum = header_checksum;
603        let mut frame_buf = vec![0u8; frame_size];
604
605        for frame_index in 0..max_frames {
606            let frame_no = frame_index.saturating_add(1);
607            // Compute in u64 to prevent usize overflow on 32-bit targets.
608            // Use the helper method which is guaranteed safe.
609            // Note: we can't call self.frame_offset because we don't have self yet.
610            // Replicate the logic here: header + index * frame_size.
611            let header_size = WAL_HEADER_SIZE as u64;
612            let idx = frame_index as u64;
613            let frame_sz = frame_size as u64;
614            let file_offset = header_size.saturating_add(idx.saturating_mul(frame_sz));
615
616            let bytes_read = file.read(cx, &mut frame_buf, file_offset).await?;
617            if bytes_read < frame_size {
618                log_replay_decision(
619                    "startup_open",
620                    frame_no,
621                    last_commit_frames,
622                    "truncated_tail_stop",
623                );
624                break; // truncated frame
625            }
626
627            // Verify salt match.
628            let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
629            if frame_header.salts != header.salts {
630                warn!(frame_index, "WAL frame salt mismatch — chain terminated");
631                log_replay_decision(
632                    "startup_open",
633                    frame_no,
634                    last_commit_frames,
635                    "salt_mismatch_stop",
636                );
637                break; // salt mismatch terminates the chain
638            }
639
640            // Verify checksum chain.
641            let expected = compute_wal_frame_checksum(
642                &frame_buf,
643                page_size,
644                running_checksum,
645                big_endian_checksum,
646            )?;
647            if frame_header.checksum != expected {
648                warn!(
649                    frame_index,
650                    "WAL frame checksum mismatch — chain terminated"
651                );
652                log_replay_decision(
653                    "startup_open",
654                    frame_no,
655                    last_commit_frames,
656                    "checksum_mismatch_stop",
657                );
658                break; // checksum mismatch terminates the chain
659            }
660
661            running_checksum = expected;
662            valid_frames += 1;
663
664            if frame_header.is_commit() {
665                last_commit_frames = valid_frames;
666                last_commit_checksum = running_checksum;
667                log_replay_decision(
668                    "startup_open",
669                    frame_no,
670                    last_commit_frames,
671                    "accept_commit",
672                );
673            } else {
674                log_replay_decision(
675                    "startup_open",
676                    frame_no,
677                    last_commit_frames,
678                    "accept_non_commit",
679                );
680            }
681        }
682
683        debug!(
684            page_size,
685            big_endian_checksum,
686            checkpoint_seq = header.checkpoint_seq,
687            valid_frames = last_commit_frames,
688            "WAL file opened"
689        );
690        crate::metrics::GLOBAL_WAL_METRICS
691            .set_wal_frames_current(u64::try_from(last_commit_frames).unwrap_or(u64::MAX));
692
693        Ok(Self {
694            file,
695            page_size,
696            big_endian_checksum,
697            header,
698            running_checksum: last_commit_checksum,
699            frame_count: last_commit_frames,
700            last_commit_frame: last_commit_frames.checked_sub(1),
701            frame_scratch: Vec::new(),
702            last_fsynced_frame_count: last_commit_frames,
703        })
704    }
705
706    /// Advance the internal WAL state after a direct, consolidated file write.
707    ///
708    /// This avoids re-reading the written frames just to update bookkeeping.
709    /// The caller must guarantee the frames were successfully synced to disk
710    /// and that the provided checksum exactly matches the end of the chain.
711    pub fn advance_state_after_write(
712        &mut self,
713        frames_written: usize,
714        new_running_checksum: SqliteWalChecksum,
715    ) -> Result<()> {
716        let new_count = self
717            .frame_count
718            .checked_add(frames_written)
719            .ok_or(FrankenError::DatabaseFull)?;
720
721        if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
722            return Err(FrankenError::DatabaseFull);
723        }
724
725        self.frame_count = new_count;
726        self.running_checksum = new_running_checksum;
727        crate::metrics::GLOBAL_WAL_METRICS
728            .set_wal_frames_current(u64::try_from(self.frame_count).unwrap_or(u64::MAX));
729        Ok(())
730    }
731
732    /// Append a frame to the WAL.
733    ///
734    /// `page_number` is the database page this frame writes.
735    /// `page_data` must be exactly `page_size` bytes.
736    /// `db_size_if_commit` should be the database size in pages for commit
737    /// frames, or 0 for non-commit frames.
738    pub async fn append_frame(
739        &mut self,
740        cx: &Cx,
741        page_number: u32,
742        page_data: &[u8],
743        db_size_if_commit: u32,
744    ) -> Result<()> {
745        if self.frame_count >= usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
746            return Err(FrankenError::DatabaseFull);
747        }
748
749        if page_data.len() != self.page_size {
750            return Err(FrankenError::WalCorrupt {
751                detail: format!(
752                    "page data size mismatch: expected {}, got {}",
753                    self.page_size,
754                    page_data.len()
755                ),
756            });
757        }
758
759        // Build the frame: header + page data.
760        let frame_size = self.frame_size();
761        let page_size = self.page_size;
762        let salts = self.header.salts;
763        let running_checksum = self.running_checksum;
764        let big_endian_checksum = self.big_endian_checksum;
765        let offset = self.frame_offset(self.frame_count);
766
767        let mut frame_scratch = std::mem::take(&mut self.frame_scratch);
768        frame_scratch.clear();
769        if frame_scratch.capacity() < frame_size {
770            frame_scratch.reserve(frame_size - frame_scratch.capacity());
771        }
772        let append_result = async {
773            push_wal_frame_bytes(
774                &mut frame_scratch,
775                page_number,
776                db_size_if_commit,
777                salts,
778                page_data,
779            );
780            let frame = &mut frame_scratch[..frame_size];
781
782            // Compute and write checksum (updates bytes 16..24 of the frame header).
783            let new_checksum =
784                write_wal_frame_checksum(frame, page_size, running_checksum, big_endian_checksum)?;
785
786            self.file.write(cx, frame, offset).await?;
787            Ok::<_, FrankenError>(new_checksum)
788        }
789        .await;
790        self.frame_scratch = frame_scratch;
791        let new_checksum = append_result?;
792
793        self.running_checksum = new_checksum;
794        self.frame_count += 1;
795        if db_size_if_commit != 0 {
796            self.last_commit_frame = Some(self.frame_count - 1);
797        }
798        crate::metrics::GLOBAL_WAL_METRICS
799            .set_wal_frames_current(u64::try_from(self.frame_count).unwrap_or(u64::MAX));
800
801        let bytes_written = u64::try_from(frame_size).unwrap_or(u64::MAX);
802        let span = tracing::span!(
803            tracing::Level::DEBUG,
804            "wal_write",
805            frame_count = self.frame_count,
806            bytes_written = bytes_written,
807            page_number = page_number,
808            is_commit = db_size_if_commit > 0,
809        );
810        let _guard = span.enter();
811
812        debug!(
813            frame_index = self.frame_count - 1,
814            page_number,
815            is_commit = db_size_if_commit > 0,
816            "WAL frame appended"
817        );
818
819        crate::metrics::GLOBAL_WAL_METRICS.record_frame_write(bytes_written);
820
821        Ok(())
822    }
823
824    /// Serialize a frame batch into contiguous WAL bytes without writing the
825    /// rolling checksum chain.
826    ///
827    /// This lets higher layers move header/payload copy work out of a
828    /// serialized append window while preserving the requirement that checksum
829    /// chaining still uses the live on-disk seed at append time.
830    pub fn prepare_frame_bytes(&self, frames: &[WalAppendFrameRef<'_>]) -> Result<Vec<u8>> {
831        let mut frame_buf = Vec::new();
832        let mut checksum_transforms = Vec::new();
833        let _ = self.prepare_frame_bytes_with_transforms_into(
834            frames.len(),
835            frames.iter().copied(),
836            &mut frame_buf,
837            &mut checksum_transforms,
838        )?;
839        Ok(frame_buf)
840    }
841
842    /// Serialize a batch of frames into caller-owned storage and precompute the
843    /// per-frame checksum transforms in the same pass.
844    ///
845    /// This lets higher layers reserve the buffer up front and avoid both an
846    /// intermediate `Vec<WalAppendFrameRef>` and a later whole-batch checksum
847    /// transform walk over the serialized bytes.
848    pub fn prepare_frame_bytes_with_transforms_into<'a, I>(
849        &self,
850        frame_count: usize,
851        frames: I,
852        frame_buf: &mut Vec<u8>,
853        checksum_transforms: &mut Vec<WalChecksumTransform>,
854    ) -> Result<Option<usize>>
855    where
856        I: IntoIterator<Item = WalAppendFrameRef<'a>>,
857    {
858        frame_buf.clear();
859        checksum_transforms.clear();
860        if frame_count == 0 {
861            return Ok(None);
862        }
863
864        let frame_size = self.frame_size();
865        let total_bytes = frame_count
866            .checked_mul(frame_size)
867            .ok_or(FrankenError::DatabaseFull)?;
868        frame_buf.resize(total_bytes, 0);
869        if checksum_transforms.capacity() < frame_count {
870            checksum_transforms.reserve(frame_count - checksum_transforms.capacity());
871        }
872
873        let mut observed_frame_count = 0usize;
874        let mut last_commit_offset = None;
875        for (idx, frame) in frames.into_iter().enumerate() {
876            if idx >= frame_count {
877                return Err(FrankenError::WalCorrupt {
878                    detail: format!(
879                        "prepared batch frame count mismatch: expected {frame_count}, got more than declared"
880                    ),
881                });
882            }
883            if frame.page_data.len() != self.page_size {
884                return Err(FrankenError::WalCorrupt {
885                    detail: format!(
886                        "page data size mismatch in batch frame {idx}: expected {}, got {}",
887                        self.page_size,
888                        frame.page_data.len()
889                    ),
890                });
891            }
892
893            let buf_offset = idx
894                .checked_mul(frame_size)
895                .ok_or(FrankenError::DatabaseFull)?;
896            let frame_slice = &mut frame_buf[buf_offset..buf_offset + frame_size];
897
898            frame_slice[..4].copy_from_slice(&frame.page_number.to_be_bytes());
899            frame_slice[4..8].copy_from_slice(&frame.db_size_if_commit.to_be_bytes());
900            write_wal_frame_salts(&mut frame_slice[..WAL_FRAME_HEADER_SIZE], self.header.salts)?;
901            frame_slice[WAL_FRAME_HEADER_SIZE..].copy_from_slice(frame.page_data);
902            checksum_transforms.push(WalChecksumTransform::for_wal_frame(
903                frame_slice,
904                self.page_size,
905                self.big_endian_checksum,
906            )?);
907            if frame.db_size_if_commit != 0 {
908                last_commit_offset = Some(idx);
909            }
910            observed_frame_count = idx.saturating_add(1);
911        }
912
913        if observed_frame_count != frame_count {
914            return Err(FrankenError::WalCorrupt {
915                detail: format!(
916                    "prepared batch frame count mismatch: expected {frame_count}, got {observed_frame_count}"
917                ),
918            });
919        }
920
921        Ok(last_commit_offset)
922    }
923
924    /// Check whether the on-disk WAL still matches a previously observed
925    /// append window.
926    ///
927    /// This is a cheap ABA-resistant probe used after a pre-lock finalize
928    /// pass. If the generation identity and frame count still match, no other
929    /// writer could have changed the append seed or target offset.
930    pub async fn prepared_append_window_still_current(
931        &self,
932        cx: &Cx,
933        generation: WalGenerationIdentity,
934        start_frame_index: usize,
935    ) -> Result<bool> {
936        let expected_size = u64::try_from(WAL_HEADER_SIZE)
937            .expect("WAL header size fits u64")
938            .saturating_add(
939                u64::try_from(start_frame_index)
940                    .unwrap_or(u64::MAX)
941                    .saturating_mul(u64::try_from(self.frame_size()).unwrap_or(u64::MAX)),
942            );
943        if self.file.file_size(cx)? != expected_size {
944            return Ok(false);
945        }
946
947        let mut header_buf = [0u8; WAL_HEADER_SIZE];
948        let header_read = self.file.read(cx, &mut header_buf, 0).await?;
949        if header_read < WAL_HEADER_SIZE {
950            return Err(FrankenError::WalCorrupt {
951                detail: format!(
952                    "WAL file too small for header during prepared append validation: read {header_read}, need {WAL_HEADER_SIZE}"
953                ),
954            });
955        }
956
957        let disk_header = WalHeader::from_bytes(&header_buf)?;
958        Ok(WalGenerationIdentity::from_header(&disk_header) == generation)
959    }
960
961    /// Finalize a previously prepared frame buffer against the current live
962    /// rolling checksum seed.
963    ///
964    /// This mutates the frame checksum fields in-place and returns the final
965    /// running checksum that should become authoritative after the eventual
966    /// durable append succeeds.
967    pub fn finalize_prepared_frame_bytes(
968        &self,
969        prepared_frame_bytes: &mut [u8],
970        frame_transforms: &[WalChecksumTransform],
971    ) -> Result<SqliteWalChecksum> {
972        let frame_count = frame_transforms.len();
973        if frame_count == 0 {
974            return Ok(self.running_checksum);
975        }
976
977        let frame_size = self.frame_size();
978        let expected_bytes = frame_count
979            .checked_mul(frame_size)
980            .ok_or(FrankenError::DatabaseFull)?;
981        if prepared_frame_bytes.len() != expected_bytes {
982            return Err(FrankenError::WalCorrupt {
983                detail: format!(
984                    "prepared batch byte length mismatch: expected {expected_bytes}, got {}",
985                    prepared_frame_bytes.len()
986                ),
987            });
988        }
989
990        let mut running_checksum = self.running_checksum;
991        for (frame_slice, frame_transform) in prepared_frame_bytes
992            .chunks_exact_mut(frame_size)
993            .zip(frame_transforms.iter())
994        {
995            write_wal_frame_salts(&mut frame_slice[..WAL_FRAME_HEADER_SIZE], self.header.salts)?;
996            running_checksum = frame_transform.apply(running_checksum);
997            write_wal_frame_checksum_fields(frame_slice, running_checksum)?;
998        }
999
1000        Ok(running_checksum)
1001    }
1002
1003    /// Append a batch whose frame bytes were already finalized against the
1004    /// current append window.
1005    pub async fn append_finalized_prepared_frame_bytes(
1006        &mut self,
1007        cx: &Cx,
1008        prepared_frame_bytes: &[u8],
1009        frame_count: usize,
1010        final_running_checksum: SqliteWalChecksum,
1011        last_commit_offset: Option<usize>,
1012    ) -> Result<()> {
1013        self.append_finalized_prepared_frame_bytes_with_completion(
1014            cx,
1015            prepared_frame_bytes,
1016            frame_count,
1017            final_running_checksum,
1018            last_commit_offset,
1019            None,
1020        )
1021        .await
1022    }
1023
1024    /// Append a finalized frame batch while retaining source-level write proof.
1025    ///
1026    /// The caller may keep a clone of `completion` across cancellation. A
1027    /// `Pending` state is in-doubt, never proof that no WAL bytes were written.
1028    pub async fn append_finalized_prepared_frame_bytes_tracked(
1029        &mut self,
1030        cx: &Cx,
1031        prepared_frame_bytes: &[u8],
1032        frame_count: usize,
1033        final_running_checksum: SqliteWalChecksum,
1034        last_commit_offset: Option<usize>,
1035        completion: VfsWriteCompletion,
1036    ) -> Result<()> {
1037        self.append_finalized_prepared_frame_bytes_with_completion(
1038            cx,
1039            prepared_frame_bytes,
1040            frame_count,
1041            final_running_checksum,
1042            last_commit_offset,
1043            Some(&completion),
1044        )
1045        .await
1046    }
1047
1048    async fn append_finalized_prepared_frame_bytes_with_completion(
1049        &mut self,
1050        cx: &Cx,
1051        prepared_frame_bytes: &[u8],
1052        frame_count: usize,
1053        final_running_checksum: SqliteWalChecksum,
1054        last_commit_offset: Option<usize>,
1055        completion: Option<&VfsWriteCompletion>,
1056    ) -> Result<()> {
1057        let mut preflight = VfsWritePreflight::new(completion);
1058        if frame_count == 0 {
1059            if let Some(completion) = completion {
1060                completion.complete_success();
1061            }
1062            preflight.hand_off();
1063            return Ok(());
1064        }
1065
1066        let new_count = self
1067            .frame_count
1068            .checked_add(frame_count)
1069            .ok_or(FrankenError::DatabaseFull)?;
1070        if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
1071            return Err(FrankenError::DatabaseFull);
1072        }
1073
1074        let frame_size = self.frame_size();
1075        let expected_bytes = frame_count
1076            .checked_mul(frame_size)
1077            .ok_or(FrankenError::DatabaseFull)?;
1078        if prepared_frame_bytes.len() != expected_bytes {
1079            return Err(FrankenError::WalCorrupt {
1080                detail: format!(
1081                    "prepared batch byte length mismatch: expected {expected_bytes}, got {}",
1082                    prepared_frame_bytes.len()
1083                ),
1084            });
1085        }
1086
1087        let start_frame_index = self.frame_count;
1088        let offset = self.frame_offset(start_frame_index);
1089
1090        #[cfg(any(test, feature = "fault-injection"))]
1091        crate::fault_hooks::maybe_inject_crash_at(
1092            crate::fault_hooks::CrashBoundary::BeforeWalFrameAppend,
1093            &format!("start_frame={start_frame_index} frame_count={frame_count}"),
1094        )?;
1095
1096        preflight.hand_off();
1097        if let Some(completion) = completion {
1098            self.file
1099                .write_tracked(cx, prepared_frame_bytes, offset, completion.clone())
1100                .await?;
1101        } else {
1102            self.file.write(cx, prepared_frame_bytes, offset).await?;
1103        }
1104        self.advance_state_after_write(frame_count, final_running_checksum)?;
1105        if let Some(last_commit_offset) = last_commit_offset {
1106            self.last_commit_frame = Some(start_frame_index + last_commit_offset);
1107        }
1108
1109        #[cfg(any(test, feature = "fault-injection"))]
1110        crate::fault_hooks::maybe_inject_crash_at(
1111            crate::fault_hooks::CrashBoundary::AfterWalFrameAppendBeforeFsync,
1112            &format!(
1113                "end_frame={} frames_written={frame_count}",
1114                self.frame_count
1115            ),
1116        )?;
1117
1118        let bytes_per_frame = u64::try_from(frame_size).unwrap_or(u64::MAX);
1119        let bytes_written = u64::try_from(expected_bytes).unwrap_or(u64::MAX);
1120        let span = tracing::span!(
1121            tracing::Level::DEBUG,
1122            "wal_batch_write",
1123            start_frame_index = start_frame_index,
1124            frames_written = frame_count,
1125            bytes_written = bytes_written,
1126        );
1127        let _guard = span.enter();
1128
1129        debug!(
1130            end_frame_count = self.frame_count,
1131            frames_written = frame_count,
1132            "WAL frames appended in batch"
1133        );
1134
1135        for _ in 0..frame_count {
1136            crate::metrics::GLOBAL_WAL_METRICS.record_frame_write(bytes_per_frame);
1137        }
1138
1139        Ok(())
1140    }
1141
1142    /// Finalize checksums for a previously prepared frame buffer and append it.
1143    ///
1144    /// `prepared_frame_bytes` must contain `frame_transforms.len()` frame
1145    /// records in WAL frame layout with page number, db_size, salts, and
1146    /// payload already serialized. The checksum bytes are overwritten in-place
1147    /// using the live rolling checksum seed from this WAL handle.
1148    pub async fn append_prepared_frame_bytes(
1149        &mut self,
1150        cx: &Cx,
1151        prepared_frame_bytes: &mut [u8],
1152        frame_transforms: &[WalChecksumTransform],
1153    ) -> Result<()> {
1154        self.append_prepared_frame_bytes_with_completion(
1155            cx,
1156            prepared_frame_bytes,
1157            frame_transforms,
1158            None,
1159        )
1160        .await
1161    }
1162
1163    /// Finalize and append prepared frame bytes with source-level write proof.
1164    pub async fn append_prepared_frame_bytes_tracked(
1165        &mut self,
1166        cx: &Cx,
1167        prepared_frame_bytes: &mut [u8],
1168        frame_transforms: &[WalChecksumTransform],
1169        completion: VfsWriteCompletion,
1170    ) -> Result<()> {
1171        self.append_prepared_frame_bytes_with_completion(
1172            cx,
1173            prepared_frame_bytes,
1174            frame_transforms,
1175            Some(&completion),
1176        )
1177        .await
1178    }
1179
1180    async fn append_prepared_frame_bytes_with_completion(
1181        &mut self,
1182        cx: &Cx,
1183        prepared_frame_bytes: &mut [u8],
1184        frame_transforms: &[WalChecksumTransform],
1185        completion: Option<&VfsWriteCompletion>,
1186    ) -> Result<()> {
1187        let mut preflight = VfsWritePreflight::new(completion);
1188        let frame_count = frame_transforms.len();
1189        if frame_count == 0 {
1190            if let Some(completion) = completion {
1191                completion.complete_success();
1192            }
1193            preflight.hand_off();
1194            return Ok(());
1195        }
1196
1197        let new_count = self
1198            .frame_count
1199            .checked_add(frame_count)
1200            .ok_or(FrankenError::DatabaseFull)?;
1201        if new_count > usize::try_from(u32::MAX).unwrap_or(usize::MAX) {
1202            return Err(FrankenError::DatabaseFull);
1203        }
1204
1205        let frame_size = self.frame_size();
1206        let running_checksum =
1207            self.finalize_prepared_frame_bytes(prepared_frame_bytes, frame_transforms)?;
1208        let last_commit_offset = prepared_frame_bytes
1209            .chunks_exact(frame_size)
1210            .enumerate()
1211            .rev()
1212            .find_map(|(offset, frame_slice)| {
1213                let db_size_if_commit = u32::from_be_bytes([
1214                    frame_slice[4],
1215                    frame_slice[5],
1216                    frame_slice[6],
1217                    frame_slice[7],
1218                ]);
1219                (db_size_if_commit != 0).then_some(offset)
1220            });
1221        preflight.hand_off();
1222        self.append_finalized_prepared_frame_bytes_with_completion(
1223            cx,
1224            prepared_frame_bytes,
1225            frame_count,
1226            running_checksum,
1227            last_commit_offset,
1228            completion,
1229        )
1230        .await
1231    }
1232
1233    /// Append a batch of frames to the WAL using a single contiguous write.
1234    ///
1235    /// This preserves the checksum chain while avoiding per-frame write
1236    /// syscalls on hot commit paths. Durability is still controlled by
1237    /// [`Self::sync`] or a higher-level caller.
1238    pub async fn append_frames(&mut self, cx: &Cx, frames: &[WalAppendFrameRef<'_>]) -> Result<()> {
1239        self.append_frame_iter(cx, frames.len(), frames.iter().copied())
1240            .await
1241    }
1242
1243    /// Append a frame batch with a caller-retained source completion token.
1244    pub async fn append_frames_tracked(
1245        &mut self,
1246        cx: &Cx,
1247        frames: &[WalAppendFrameRef<'_>],
1248        completion: VfsWriteCompletion,
1249    ) -> Result<()> {
1250        self.append_frame_iter_tracked(cx, frames.len(), frames.iter().copied(), completion)
1251            .await
1252    }
1253
1254    /// Append a known-size iterator of frame references without first
1255    /// materializing a borrowed descriptor slice.
1256    pub(crate) async fn append_frame_iter<'a, I>(
1257        &mut self,
1258        cx: &Cx,
1259        frame_count: usize,
1260        frames: I,
1261    ) -> Result<()>
1262    where
1263        I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1264    {
1265        self.append_frame_iter_with_completion(cx, frame_count, frames, None)
1266            .await
1267    }
1268
1269    pub(crate) async fn append_frame_iter_tracked<'a, I>(
1270        &mut self,
1271        cx: &Cx,
1272        frame_count: usize,
1273        frames: I,
1274        completion: VfsWriteCompletion,
1275    ) -> Result<()>
1276    where
1277        I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1278    {
1279        self.append_frame_iter_with_completion(cx, frame_count, frames, Some(&completion))
1280            .await
1281    }
1282
1283    async fn append_frame_iter_with_completion<'a, I>(
1284        &mut self,
1285        cx: &Cx,
1286        frame_count: usize,
1287        frames: I,
1288        completion: Option<&VfsWriteCompletion>,
1289    ) -> Result<()>
1290    where
1291        I: IntoIterator<Item = WalAppendFrameRef<'a>>,
1292    {
1293        let mut preflight = VfsWritePreflight::new(completion);
1294        if frame_count == 0 {
1295            if let Some(completion) = completion {
1296                completion.complete_success();
1297            }
1298            preflight.hand_off();
1299            return Ok(());
1300        }
1301
1302        #[cfg(any(test, feature = "fault-injection"))]
1303        crate::fault_hooks::maybe_inject_append_busy(self.frame_count, frame_count)?;
1304
1305        let frame_size = self.frame_size();
1306        let total_bytes = frame_count
1307            .checked_mul(frame_size)
1308            .ok_or(FrankenError::DatabaseFull)?;
1309        let page_size = self.page_size;
1310        let salts = self.header.salts;
1311        let big_endian_checksum = self.big_endian_checksum;
1312        #[cfg(any(test, feature = "fault-injection"))]
1313        let frame_count_before = self.frame_count;
1314
1315        let mut frame_scratch = std::mem::take(&mut self.frame_scratch);
1316        frame_scratch.clear();
1317        if frame_scratch.capacity() < total_bytes {
1318            frame_scratch.reserve(total_bytes - frame_scratch.capacity());
1319        }
1320        // bd-db300.3.8.6: Fuse frame assembly + checksum computation into a
1321        // single pass, eliminating the intermediate Vec<WalChecksumTransform>
1322        // allocation and the redundant second write_wal_frame_salts call that
1323        // finalize_prepared_frame_bytes performed.
1324        let append_result = async {
1325            let mut running_checksum = self.running_checksum;
1326            let mut last_commit_offset: Option<usize> = None;
1327            let mut observed_frame_count = 0usize;
1328
1329            for (idx, frame) in frames.into_iter().enumerate() {
1330                if idx >= frame_count {
1331                    return Err(FrankenError::WalCorrupt {
1332                        detail: format!(
1333                            "append batch frame count mismatch: expected {frame_count}, got more than declared"
1334                        ),
1335                    });
1336                }
1337                if frame.page_data.len() != page_size {
1338                    return Err(FrankenError::WalCorrupt {
1339                        detail: format!(
1340                            "page data size mismatch in batch frame {idx}: expected {page_size}, got {}",
1341                            frame.page_data.len()
1342                        ),
1343                    });
1344                }
1345
1346                let buf_offset = idx
1347                    .checked_mul(frame_size)
1348                    .ok_or(FrankenError::DatabaseFull)?;
1349
1350                // Build the frame: page_number, db_size, salts, page data.
1351                push_wal_frame_bytes(
1352                    &mut frame_scratch,
1353                    frame.page_number,
1354                    frame.db_size_if_commit,
1355                    salts,
1356                    frame.page_data,
1357                );
1358                let frame_slice = &mut frame_scratch[buf_offset..buf_offset + frame_size];
1359
1360                // Compute and write the checksum inline — no transform Vec needed.
1361                running_checksum = write_wal_frame_checksum(
1362                    frame_slice,
1363                    page_size,
1364                    running_checksum,
1365                    big_endian_checksum,
1366                )?;
1367
1368                if frame.db_size_if_commit != 0 {
1369                    last_commit_offset = Some(idx);
1370                }
1371                observed_frame_count = idx + 1;
1372            }
1373
1374            if observed_frame_count != frame_count {
1375                return Err(FrankenError::WalCorrupt {
1376                    detail: format!(
1377                        "append batch frame count mismatch: expected {frame_count}, got {observed_frame_count}"
1378                    ),
1379                });
1380            }
1381
1382            preflight.hand_off();
1383            self.append_finalized_prepared_frame_bytes_with_completion(
1384                cx,
1385                &frame_scratch,
1386                frame_count,
1387                running_checksum,
1388                last_commit_offset,
1389                completion,
1390            )
1391            .await
1392        }
1393        .await;
1394        self.frame_scratch = frame_scratch;
1395
1396        #[cfg(any(test, feature = "fault-injection"))]
1397        if append_result.is_ok() {
1398            crate::fault_hooks::maybe_inject_after_append(frame_count_before, frame_count)?;
1399        }
1400
1401        append_result
1402    }
1403
1404    /// Read a frame by 0-based index, returning header and page data.
1405    pub async fn read_frame(
1406        &self,
1407        cx: &Cx,
1408        frame_index: usize,
1409    ) -> Result<(WalFrameHeader, Vec<u8>)> {
1410        let frame_size = self.frame_size();
1411        let mut buf = vec![0u8; frame_size];
1412        let header = self.read_frame_into(cx, frame_index, &mut buf).await?;
1413        let page_data = buf[WAL_FRAME_HEADER_SIZE..].to_vec();
1414        Ok((header, page_data))
1415    }
1416
1417    /// Read a frame into a provided buffer, returning the header.
1418    ///
1419    /// `buf` must be at least `frame_size` bytes. The frame header is parsed
1420    /// from the beginning of the buffer, and the page data follows immediately
1421    /// after at offset `WAL_FRAME_HEADER_SIZE`.
1422    pub async fn read_frame_into(
1423        &self,
1424        cx: &Cx,
1425        frame_index: usize,
1426        buf: &mut [u8],
1427    ) -> Result<WalFrameHeader> {
1428        if frame_index >= self.frame_count {
1429            return Err(FrankenError::WalCorrupt {
1430                detail: format!(
1431                    "frame index {frame_index} out of range (count: {})",
1432                    self.frame_count
1433                ),
1434            });
1435        }
1436
1437        let frame_size = self.frame_size();
1438        if buf.len() < frame_size {
1439            return Err(FrankenError::Internal(format!(
1440                "read_frame_into buffer too small: got {}, need {}",
1441                buf.len(),
1442                frame_size
1443            )));
1444        }
1445
1446        let offset = self.frame_offset(frame_index);
1447        let bytes_read = self.file.read(cx, &mut buf[..frame_size], offset).await?;
1448        if bytes_read < frame_size {
1449            return Err(FrankenError::WalCorrupt {
1450                detail: format!(
1451                    "short read at frame {frame_index}: got {bytes_read}, need {frame_size}"
1452                ),
1453            });
1454        }
1455
1456        WalFrameHeader::from_bytes(&buf[..WAL_FRAME_HEADER_SIZE])
1457    }
1458
1459    /// Read just the frame header at a given 0-based index.
1460    pub async fn read_frame_header(&self, cx: &Cx, frame_index: usize) -> Result<WalFrameHeader> {
1461        if frame_index >= self.frame_count {
1462            return Err(FrankenError::WalCorrupt {
1463                detail: format!(
1464                    "frame index {frame_index} out of range (count: {})",
1465                    self.frame_count
1466                ),
1467            });
1468        }
1469
1470        let mut header_buf = [0u8; WAL_FRAME_HEADER_SIZE];
1471        let offset = self.frame_offset(frame_index);
1472        let bytes_read = self.file.read(cx, &mut header_buf, offset).await?;
1473        if bytes_read < WAL_FRAME_HEADER_SIZE {
1474            return Err(FrankenError::WalCorrupt {
1475                detail: format!("short header read at frame {frame_index}: got {bytes_read}"),
1476            });
1477        }
1478
1479        WalFrameHeader::from_bytes(&header_buf)
1480    }
1481
1482    /// Find the last commit frame index, or `None` if there are no commits.
1483    pub fn last_commit_frame(&mut self, cx: &Cx) -> Result<Option<usize>> {
1484        let _ = cx;
1485        Ok(self.last_commit_frame)
1486    }
1487
1488    /// Sync the WAL file to stable storage and record every appended frame
1489    /// covered by the successful sync for the two-phase publish invariant.
1490    pub fn sync(&mut self, cx: &Cx, flags: SyncFlags) -> Result<()> {
1491        #[cfg(any(test, feature = "fault-injection"))]
1492        crate::fault_hooks::maybe_inject_sync_failure(self.frame_count, flags)?;
1493
1494        self.file.sync(cx, flags)?;
1495        self.last_fsynced_frame_count = self.frame_count;
1496        Ok(())
1497    }
1498
1499    /// Remove every physical byte after the checksum-valid committed prefix
1500    /// represented by this handle.
1501    ///
1502    /// Callers must first refresh the handle while holding the external writer
1503    /// gate. This is the recovery-side counterpart to append completion
1504    /// tracking: a terminal write error can still leave a partial frame or a
1505    /// complete but uncommitted interval, neither of which may be reused as an
1506    /// append base.
1507    pub fn repair_uncommitted_tail(&mut self, cx: &Cx) -> Result<()> {
1508        let committed_size = u64::try_from(WAL_HEADER_SIZE)
1509            .unwrap_or(u64::MAX)
1510            .checked_add(
1511                u64::try_from(self.frame_count)
1512                    .unwrap_or(u64::MAX)
1513                    .checked_mul(u64::try_from(self.frame_size()).unwrap_or(u64::MAX))
1514                    .ok_or(FrankenError::DatabaseFull)?,
1515            )
1516            .ok_or(FrankenError::DatabaseFull)?;
1517        let file_size = self.file.file_size(cx)?;
1518        if file_size < committed_size {
1519            return Err(FrankenError::WalCorrupt {
1520                detail: format!(
1521                    "WAL file shrank below its committed prefix: file_size={file_size}, committed_size={committed_size}"
1522                ),
1523            });
1524        }
1525        if file_size > committed_size {
1526            self.file.truncate(cx, committed_size)?;
1527        }
1528        Ok(())
1529    }
1530
1531    /// Durability-intent sync: makes all appended frames durable and records
1532    /// the fsynced frame count for the two-phase commit invariant.
1533    ///
1534    /// This is the intent-preserving form of [`Self::sync`]. Both successful
1535    /// sync paths advance the invariant tracker before a caller can publish.
1536    pub fn durable_sync(&mut self, cx: &Cx, kind: SyncKind) -> Result<()> {
1537        #[cfg(any(test, feature = "fault-injection"))]
1538        {
1539            let flags = match kind {
1540                SyncKind::DataOnly => SyncFlags::DATAONLY,
1541                SyncKind::DataAndMetadata | SyncKind::FullDurable => SyncFlags::FULL,
1542            };
1543            crate::fault_hooks::maybe_inject_sync_failure(self.frame_count, flags)?;
1544        }
1545
1546        self.file.durable_sync(cx, kind)?;
1547        self.last_fsynced_frame_count = self.frame_count;
1548
1549        debug!(
1550            target: "fsqlite_wal::durability",
1551            fsynced_up_to = self.frame_count,
1552            kind = ?kind,
1553            "WAL durable sync complete"
1554        );
1555
1556        #[cfg(any(test, feature = "fault-injection"))]
1557        crate::fault_hooks::maybe_inject_crash_at(
1558            crate::fault_hooks::CrashBoundary::AfterFsyncBeforePublish,
1559            &format!("fsynced_up_to={}", self.frame_count),
1560        )?;
1561
1562        Ok(())
1563    }
1564
1565    /// Assert that it is safe to publish frames up to `publish_frame_count`
1566    /// — i.e. that a successful sync has already completed covering those frames.
1567    ///
1568    /// Under debug-assertions this panics. In release mode, it returns an error
1569    /// only when `FRANKENSQLITE_PARANOID_DURABILITY=1` is set.
1570    pub fn assert_publish_safe(&self, publish_frame_count: usize) -> Result<()> {
1571        if self.last_fsynced_frame_count >= publish_frame_count {
1572            return Ok(());
1573        }
1574
1575        let msg = format!(
1576            "publish-before-fsync: attempting to publish frame_count={publish_frame_count} \
1577             but last fsynced only up to {fsynced}",
1578            fsynced = self.last_fsynced_frame_count,
1579        );
1580
1581        debug_assert!(false, "WAL durability invariant violated: {msg}");
1582
1583        if *PARANOID_DURABILITY {
1584            error!(
1585                target: "fsqlite_wal::durability",
1586                publish_frame_count,
1587                last_fsynced = self.last_fsynced_frame_count,
1588                "PARANOID_DURABILITY: publish-before-fsync detected"
1589            );
1590            return Err(FrankenError::Internal(msg));
1591        }
1592
1593        Ok(())
1594    }
1595
1596    /// The frame count covered by the last successful WAL sync.
1597    #[must_use]
1598    pub fn last_fsynced_frame_count(&self) -> usize {
1599        self.last_fsynced_frame_count
1600    }
1601
1602    /// Reset the WAL for a new checkpoint generation.
1603    ///
1604    /// Writes a new header with updated checkpoint sequence and salts,
1605    /// and resets the running checksum and frame count to zero.
1606    /// If `truncate_file` is true, also truncates the file to header-only.
1607    pub async fn reset(
1608        &mut self,
1609        cx: &Cx,
1610        new_checkpoint_seq: u32,
1611        new_salts: WalSalts,
1612        truncate_file: bool,
1613    ) -> Result<()> {
1614        let new_header = WalHeader {
1615            magic: self.header.magic,
1616            format_version: WAL_FORMAT_VERSION,
1617            page_size: self.header.page_size,
1618            checkpoint_seq: new_checkpoint_seq,
1619            salts: new_salts,
1620            checksum: SqliteWalChecksum::default(),
1621        };
1622        let header_bytes = new_header.to_bytes()?;
1623
1624        #[cfg(any(test, feature = "fault-injection"))]
1625        crate::fault_hooks::maybe_inject_crash_at(
1626            crate::fault_hooks::CrashBoundary::BeforeWalHeaderWrite,
1627            &format!("checkpoint_seq={new_checkpoint_seq}"),
1628        )?;
1629
1630        self.file.write(cx, &header_bytes, 0).await?;
1631
1632        // H9 fault hook: crash after header write, before truncate.
1633        // Simulates power loss leaving new salts in the header but old
1634        // frames still on disk. Recovery must see the salt mismatch and
1635        // discard all old-generation frames.
1636        #[cfg(any(test, feature = "fault-injection"))]
1637        {
1638            let old_fc = self.frame_count;
1639            crate::fault_hooks::maybe_inject_crash_header_truncate(old_fc, new_checkpoint_seq)?;
1640        }
1641
1642        if truncate_file {
1643            self.file.truncate(
1644                cx,
1645                u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
1646            )?;
1647        }
1648
1649        // Sync the WAL header to stable storage before writing new frames,
1650        // matching SQLite's walRestartHdr() behaviour.
1651        self.file.sync(cx, SyncFlags::NORMAL)?;
1652
1653        self.running_checksum = read_wal_header_checksum(&header_bytes)?;
1654        self.header = WalHeader::from_bytes(&header_bytes)?;
1655        self.frame_count = 0;
1656        self.last_commit_frame = None;
1657        self.last_fsynced_frame_count = 0;
1658        self.frame_scratch.clear();
1659        crate::metrics::GLOBAL_WAL_METRICS.set_wal_frames_current(0);
1660
1661        debug!(
1662            checkpoint_seq = new_checkpoint_seq,
1663            salt1 = new_salts.salt1,
1664            salt2 = new_salts.salt2,
1665            "WAL reset"
1666        );
1667
1668        crate::metrics::GLOBAL_WAL_METRICS.record_wal_reset();
1669
1670        Ok(())
1671    }
1672
1673    /// Consume this `WalFile` and close the underlying VFS file handle.
1674    pub fn close(mut self, cx: &Cx) -> Result<()> {
1675        self.file.close(cx)
1676    }
1677
1678    /// Return a reference to the underlying VFS file handle.
1679    #[must_use]
1680    pub fn file(&self) -> &F {
1681        &self.file
1682    }
1683
1684    /// Return a mutable reference to the underlying VFS file handle.
1685    pub fn file_mut(&mut self) -> &mut F {
1686        &mut self.file
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use std::time::Instant;
1693
1694    use fsqlite_types::flags::VfsOpenFlags;
1695    use fsqlite_vfs::MemoryVfs;
1696    use fsqlite_vfs::traits::Vfs;
1697    use serde_json::{Value, json};
1698
1699    use super::*;
1700    use crate::test_support::FutureResultTestExt as _;
1701
1702    /// Shared, panic-safe ownership guard for process-global fault hooks.
1703    static FAULT_TEST_LOCK: crate::fault_hooks::FaultInjectionSessionLock =
1704        crate::fault_hooks::FaultInjectionSessionLock::new();
1705
1706    const PAGE_SIZE: u32 = 4096;
1707    const TRACK_C_SCRATCH_BENCH_BEAD_ID: &str = "bd-db300.3.4.3";
1708    const TRACK_C_SCRATCH_BENCH_WARMUP_ITERS: usize = 4;
1709    const TRACK_C_SCRATCH_BENCH_MEASURE_ITERS: usize = 12;
1710
1711    #[derive(Clone, Copy)]
1712    enum TrackCScratchBenchMode {
1713        FreshAllocBaseline,
1714        ScratchReuseCandidate,
1715    }
1716
1717    #[derive(Clone, Copy)]
1718    struct TrackCScratchBenchRun {
1719        elapsed_ns: u64,
1720        explicit_fresh_buffer_allocations: usize,
1721        scratch_capacity_growth_events: usize,
1722        peak_scratch_capacity_bytes: usize,
1723        frame_buffer_bytes_per_operation: usize,
1724        operations_per_sample: usize,
1725    }
1726
1727    fn test_cx() -> Cx {
1728        Cx::default()
1729    }
1730
1731    fn test_salts() -> WalSalts {
1732        WalSalts {
1733            salt1: 0xDEAD_BEEF,
1734            salt2: 0xCAFE_BABE,
1735        }
1736    }
1737
1738    fn sample_page(seed: u8) -> Vec<u8> {
1739        let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
1740        let mut page = vec![0u8; page_size];
1741        for (i, byte) in page.iter_mut().enumerate() {
1742            let reduced = u8::try_from(i % 251).expect("modulo fits u8");
1743            *byte = reduced ^ seed;
1744        }
1745        page
1746    }
1747
1748    fn frame_ref(
1749        page_number: u32,
1750        page_data: &[u8],
1751        db_size_if_commit: u32,
1752    ) -> WalAppendFrameRef<'_> {
1753        WalAppendFrameRef {
1754            page_number,
1755            page_data,
1756            db_size_if_commit,
1757        }
1758    }
1759
1760    fn open_wal_file(vfs: &MemoryVfs, cx: &Cx) -> <MemoryVfs as Vfs>::File {
1761        let flags = VfsOpenFlags::READWRITE | VfsOpenFlags::CREATE | VfsOpenFlags::WAL;
1762        let (file, _) = vfs
1763            .open(cx, Some(std::path::Path::new("test.db-wal")), flags)
1764            .expect("open WAL file");
1765        file
1766    }
1767
1768    fn track_c_scratch_run_summary(runs: &[TrackCScratchBenchRun]) -> Value {
1769        let mut elapsed_samples: Vec<_> = runs.iter().map(|run| run.elapsed_ns).collect();
1770        elapsed_samples.sort_unstable();
1771        let sample_count = elapsed_samples.len();
1772        let min_ns = elapsed_samples.first().copied().unwrap_or(0);
1773        let median_ns = if sample_count == 0 {
1774            0
1775        } else {
1776            elapsed_samples[sample_count / 2]
1777        };
1778        let max_ns = elapsed_samples.last().copied().unwrap_or(0);
1779        let mean_ns = if sample_count == 0 {
1780            0.0
1781        } else {
1782            let total_ns: u128 = elapsed_samples.iter().map(|ns| u128::from(*ns)).sum();
1783            (total_ns as f64) / (sample_count as f64)
1784        };
1785        let explicit_fresh_buffer_allocations = runs
1786            .iter()
1787            .map(|run| run.explicit_fresh_buffer_allocations)
1788            .max()
1789            .unwrap_or(0);
1790        let scratch_capacity_growth_events = runs
1791            .iter()
1792            .map(|run| run.scratch_capacity_growth_events)
1793            .max()
1794            .unwrap_or(0);
1795        let peak_scratch_capacity_bytes = runs
1796            .iter()
1797            .map(|run| run.peak_scratch_capacity_bytes)
1798            .max()
1799            .unwrap_or(0);
1800        let frame_buffer_bytes_per_operation = runs
1801            .first()
1802            .map(|run| run.frame_buffer_bytes_per_operation)
1803            .unwrap_or(0);
1804        let operations_per_sample = runs
1805            .first()
1806            .map(|run| run.operations_per_sample)
1807            .unwrap_or(0);
1808
1809        json!({
1810            "samples_ns": elapsed_samples,
1811            "min_ns": min_ns,
1812            "median_ns": median_ns,
1813            "max_ns": max_ns,
1814            "mean_ns": mean_ns,
1815            "explicit_fresh_buffer_allocations_per_sample": explicit_fresh_buffer_allocations,
1816            "scratch_capacity_growth_events_per_sample": scratch_capacity_growth_events,
1817            "peak_scratch_capacity_bytes": peak_scratch_capacity_bytes,
1818            "frame_buffer_bytes_per_operation": frame_buffer_bytes_per_operation,
1819            "operations_per_sample": operations_per_sample,
1820            "frame_buffer_bytes_requested_per_sample": explicit_fresh_buffer_allocations
1821                .saturating_mul(frame_buffer_bytes_per_operation),
1822        })
1823    }
1824
1825    fn append_frames_fresh_alloc<F: VfsFile>(
1826        wal: &mut WalFile<F>,
1827        cx: &Cx,
1828        frames: &[WalAppendFrameRef<'_>],
1829    ) -> Result<()> {
1830        let frame_size = wal.frame_size();
1831        let mut frame_buf = wal.prepare_frame_bytes(frames)?;
1832        let frame_transforms = frame_buf
1833            .chunks_exact(frame_size)
1834            .map(|frame| {
1835                WalChecksumTransform::for_wal_frame(
1836                    frame,
1837                    wal.page_size(),
1838                    wal.big_endian_checksum(),
1839                )
1840            })
1841            .collect::<Result<Vec<_>>>()?;
1842        wal.append_prepared_frame_bytes(cx, &mut frame_buf, &frame_transforms)
1843            .wait()
1844    }
1845
1846    fn track_c_measure_single_frame_case(
1847        mode: TrackCScratchBenchMode,
1848        operations: usize,
1849    ) -> TrackCScratchBenchRun {
1850        let cx = test_cx();
1851        let vfs = MemoryVfs::new();
1852        let file = open_wal_file(&vfs, &cx);
1853        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
1854        let pages: Vec<Vec<u8>> = (0..operations)
1855            .map(|i| sample_page(u8::try_from(i % 251).expect("modulo fits u8")))
1856            .collect();
1857        let frame_buffer_bytes_per_operation = wal.frame_size();
1858        let mut explicit_fresh_buffer_allocations = 0usize;
1859        let mut scratch_capacity_growth_events = 0usize;
1860        let mut previous_scratch_capacity = 0usize;
1861
1862        let start = Instant::now();
1863        for (idx, page) in pages.iter().enumerate() {
1864            let page_number = u32::try_from(idx).expect("index fits u32") + 1;
1865            match mode {
1866                TrackCScratchBenchMode::FreshAllocBaseline => {
1867                    let frames = [WalAppendFrameRef {
1868                        page_number,
1869                        page_data: page,
1870                        db_size_if_commit: page_number,
1871                    }];
1872                    append_frames_fresh_alloc(&mut wal, &cx, &frames).expect("append baseline");
1873                    explicit_fresh_buffer_allocations += 1;
1874                }
1875                TrackCScratchBenchMode::ScratchReuseCandidate => {
1876                    wal.append_frame(&cx, page_number, page, page_number)
1877                        .expect("append candidate");
1878                    let scratch_capacity = wal.frame_scratch_capacity();
1879                    if scratch_capacity > previous_scratch_capacity {
1880                        scratch_capacity_growth_events += 1;
1881                        previous_scratch_capacity = scratch_capacity;
1882                    }
1883                }
1884            }
1885        }
1886
1887        TrackCScratchBenchRun {
1888            elapsed_ns: u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX),
1889            explicit_fresh_buffer_allocations,
1890            scratch_capacity_growth_events,
1891            peak_scratch_capacity_bytes: wal.frame_scratch_capacity(),
1892            frame_buffer_bytes_per_operation,
1893            operations_per_sample: operations,
1894        }
1895    }
1896
1897    fn track_c_measure_batch_case<const N: usize>(
1898        mode: TrackCScratchBenchMode,
1899        operations: usize,
1900    ) -> TrackCScratchBenchRun {
1901        let cx = test_cx();
1902        let vfs = MemoryVfs::new();
1903        let file = open_wal_file(&vfs, &cx);
1904        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
1905        let pages_per_operation: Vec<[Vec<u8>; N]> = (0..operations)
1906            .map(|operation_idx| {
1907                std::array::from_fn(|frame_idx| {
1908                    sample_page(
1909                        u8::try_from((operation_idx * N + frame_idx) % 251)
1910                            .expect("modulo fits u8"),
1911                    )
1912                })
1913            })
1914            .collect();
1915        let frame_buffer_bytes_per_operation = wal
1916            .frame_size()
1917            .checked_mul(N)
1918            .expect("frame bytes per operation fit usize");
1919        let mut explicit_fresh_buffer_allocations = 0usize;
1920        let mut scratch_capacity_growth_events = 0usize;
1921        let mut previous_scratch_capacity = 0usize;
1922
1923        let start = Instant::now();
1924        for (operation_idx, pages) in pages_per_operation.iter().enumerate() {
1925            let page_base = u32::try_from(
1926                operation_idx
1927                    .checked_mul(N)
1928                    .expect("operation frame base fits usize"),
1929            )
1930            .expect("frame base fits u32")
1931                + 1;
1932            let commit_db_size = page_base + u32::try_from(N).expect("N fits u32") - 1;
1933            let frames: [WalAppendFrameRef<'_>; N] =
1934                std::array::from_fn(|frame_idx| WalAppendFrameRef {
1935                    page_number: page_base
1936                        + u32::try_from(frame_idx).expect("frame index fits u32"),
1937                    page_data: &pages[frame_idx],
1938                    db_size_if_commit: if frame_idx + 1 == N {
1939                        commit_db_size
1940                    } else {
1941                        0
1942                    },
1943                });
1944
1945            match mode {
1946                TrackCScratchBenchMode::FreshAllocBaseline => {
1947                    append_frames_fresh_alloc(&mut wal, &cx, &frames).expect("append baseline");
1948                    explicit_fresh_buffer_allocations += 1;
1949                }
1950                TrackCScratchBenchMode::ScratchReuseCandidate => {
1951                    wal.append_frames(&cx, &frames).expect("append candidate");
1952                    let scratch_capacity = wal.frame_scratch_capacity();
1953                    if scratch_capacity > previous_scratch_capacity {
1954                        scratch_capacity_growth_events += 1;
1955                        previous_scratch_capacity = scratch_capacity;
1956                    }
1957                }
1958            }
1959        }
1960
1961        TrackCScratchBenchRun {
1962            elapsed_ns: u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX),
1963            explicit_fresh_buffer_allocations,
1964            scratch_capacity_growth_events,
1965            peak_scratch_capacity_bytes: wal.frame_scratch_capacity(),
1966            frame_buffer_bytes_per_operation,
1967            operations_per_sample: operations,
1968        }
1969    }
1970
1971    fn track_c_scratch_case_report(
1972        scenario_id: &str,
1973        frames_per_operation: usize,
1974        operations_per_sample: usize,
1975        baseline_measure: impl Fn() -> TrackCScratchBenchRun,
1976        candidate_measure: impl Fn() -> TrackCScratchBenchRun,
1977    ) -> Value {
1978        for _ in 0..TRACK_C_SCRATCH_BENCH_WARMUP_ITERS {
1979            let _ = baseline_measure();
1980            let _ = candidate_measure();
1981        }
1982
1983        let baseline_runs: Vec<_> = (0..TRACK_C_SCRATCH_BENCH_MEASURE_ITERS)
1984            .map(|_| baseline_measure())
1985            .collect();
1986        let candidate_runs: Vec<_> = (0..TRACK_C_SCRATCH_BENCH_MEASURE_ITERS)
1987            .map(|_| candidate_measure())
1988            .collect();
1989        let baseline_summary = track_c_scratch_run_summary(&baseline_runs);
1990        let candidate_summary = track_c_scratch_run_summary(&candidate_runs);
1991        let baseline_median = baseline_summary["median_ns"].as_u64().unwrap_or(0);
1992        let candidate_median = candidate_summary["median_ns"].as_u64().unwrap_or(0);
1993        let baseline_allocations = baseline_summary["explicit_fresh_buffer_allocations_per_sample"]
1994            .as_u64()
1995            .unwrap_or(0);
1996        let candidate_growths = candidate_summary["scratch_capacity_growth_events_per_sample"]
1997            .as_u64()
1998            .unwrap_or(0);
1999        let baseline_requested_bytes = baseline_summary["frame_buffer_bytes_requested_per_sample"]
2000            .as_u64()
2001            .unwrap_or(0);
2002        let candidate_peak_scratch_bytes = candidate_summary["peak_scratch_capacity_bytes"]
2003            .as_u64()
2004            .unwrap_or(0);
2005
2006        json!({
2007            "scenario_id": scenario_id,
2008            "frames_per_operation": frames_per_operation,
2009            "operations_per_sample": operations_per_sample,
2010            "fresh_alloc_baseline": baseline_summary,
2011            "scratch_reuse_candidate": candidate_summary,
2012            "fresh_buffer_allocations_avoided_per_sample": baseline_allocations.saturating_sub(candidate_growths),
2013            "buffer_bytes_saved_vs_fresh_requested_per_sample": baseline_requested_bytes.saturating_sub(candidate_peak_scratch_bytes),
2014            "speedup_vs_baseline_median": if candidate_median == 0 {
2015                0.0
2016            } else {
2017                (baseline_median as f64) / (candidate_median as f64)
2018            },
2019            "faster_variant_by_median": if candidate_median <= baseline_median {
2020                "scratch_reuse_candidate"
2021            } else {
2022                "fresh_alloc_baseline"
2023            },
2024        })
2025    }
2026
2027    #[test]
2028    fn test_create_and_open_empty_wal() {
2029        let cx = test_cx();
2030        let vfs = MemoryVfs::new();
2031        let file = open_wal_file(&vfs, &cx);
2032
2033        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2034        assert_eq!(wal.frame_count(), 0);
2035        assert_eq!(wal.page_size(), usize::try_from(PAGE_SIZE).unwrap());
2036        assert!(!wal.big_endian_checksum());
2037        assert_eq!(wal.header().checkpoint_seq, 0);
2038        assert_eq!(wal.header().salts, test_salts());
2039
2040        wal.close(&cx).expect("close WAL");
2041
2042        // Reopen and verify.
2043        let file2 = open_wal_file(&vfs, &cx);
2044        let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2045        assert_eq!(wal2.frame_count(), 0);
2046        assert_eq!(wal2.header().salts, test_salts());
2047
2048        wal2.close(&cx).expect("close WAL");
2049    }
2050
2051    #[test]
2052    fn test_append_and_read_single_frame() {
2053        let cx = test_cx();
2054        let vfs = MemoryVfs::new();
2055        let file = open_wal_file(&vfs, &cx);
2056
2057        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2058
2059        let page = sample_page(0x42);
2060        wal.append_frame(&cx, 1, &page, 0).expect("append frame");
2061        assert_eq!(wal.frame_count(), 1);
2062
2063        let (header, data) = wal.read_frame(&cx, 0).expect("read frame");
2064        assert_eq!(header.page_number, 1);
2065        assert_eq!(header.db_size, 0);
2066        assert_eq!(header.salts, test_salts());
2067        assert_eq!(data, page);
2068
2069        wal.close(&cx).expect("close WAL");
2070    }
2071
2072    #[test]
2073    fn test_fault_hook_after_wal_append_returns_error_and_records_context() {
2074        let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2075        crate::fault_hooks::clear();
2076
2077        let cx = test_cx();
2078        let vfs = MemoryVfs::new();
2079        let file = open_wal_file(&vfs, &cx);
2080        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2081        let page = sample_page(0x33);
2082        let frames = [WalAppendFrameRef {
2083            page_number: 1,
2084            page_data: &page,
2085            db_size_if_commit: 1,
2086        }];
2087
2088        crate::fault_hooks::arm_after_append(crate::fault_hooks::FaultHookArm::new(
2089            "bd-db300.7.2.2-after-append",
2090            "WAL-AFTER-APPEND",
2091            "wal_append_recovery",
2092        ));
2093
2094        let error = wal
2095            .append_frames(&cx, &frames)
2096            .expect_err("fault hook should force an error after append");
2097        assert!(
2098            error.to_string().contains("fault_inject:wal_after_append"),
2099            "fault error should identify the append hook: {error}"
2100        );
2101        assert_eq!(
2102            wal.frame_count(),
2103            1,
2104            "append should still have reached the WAL"
2105        );
2106
2107        wal.close(&cx).expect("close WAL");
2108        let reopened_file = open_wal_file(&vfs, &cx);
2109        let reopened = WalFile::open(&cx, reopened_file).expect("reopen WAL");
2110        assert_eq!(
2111            reopened.frame_count(),
2112            1,
2113            "reopened WAL should preserve the appended frame for later recovery checks"
2114        );
2115
2116        let records = crate::fault_hooks::take_records();
2117        assert_eq!(
2118            records.len(),
2119            1,
2120            "exactly one append fault should be recorded"
2121        );
2122        assert_eq!(records[0].point, "wal_after_append");
2123        assert_eq!(records[0].run_id, "bd-db300.7.2.2-after-append");
2124        assert_eq!(records[0].scenario_id, "WAL-AFTER-APPEND");
2125        assert_eq!(records[0].invariant_family, "wal_append_recovery");
2126        assert!(
2127            records[0].detail.contains("appended_frames=1"),
2128            "record should preserve append context: {}",
2129            records[0].detail
2130        );
2131
2132        crate::fault_hooks::clear();
2133    }
2134
2135    #[test]
2136    fn test_append_commit_frame() {
2137        let cx = test_cx();
2138        let vfs = MemoryVfs::new();
2139        let file = open_wal_file(&vfs, &cx);
2140
2141        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2142
2143        let page = sample_page(0x10);
2144        wal.append_frame(&cx, 5, &page, 10)
2145            .expect("append commit frame");
2146
2147        let header = wal.read_frame_header(&cx, 0).expect("read header");
2148        assert!(header.is_commit());
2149        assert_eq!(header.db_size, 10);
2150        assert_eq!(header.page_number, 5);
2151
2152        wal.close(&cx).expect("close WAL");
2153    }
2154
2155    #[test]
2156    fn test_multi_frame_checksum_chain() {
2157        let cx = test_cx();
2158        let vfs = MemoryVfs::new();
2159        let file = open_wal_file(&vfs, &cx);
2160
2161        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 3, test_salts()).expect("create WAL");
2162
2163        // Append 5 frames, last is commit.
2164        for i in 0..5u32 {
2165            let page = sample_page(u8::try_from(i).expect("fits"));
2166            let db_size = if i == 4 { 5 } else { 0 };
2167            wal.append_frame(&cx, i + 1, &page, db_size)
2168                .expect("append frame");
2169        }
2170        assert_eq!(wal.frame_count(), 5);
2171
2172        wal.close(&cx).expect("close WAL");
2173
2174        // Reopen and verify all frames are valid (checksum chain intact).
2175        let file2 = open_wal_file(&vfs, &cx);
2176        let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2177        assert_eq!(wal2.frame_count(), 5);
2178
2179        // Verify each frame's content.
2180        for i in 0..5u32 {
2181            let (header, data) = wal2
2182                .read_frame(&cx, usize::try_from(i).unwrap())
2183                .expect("read frame");
2184            assert_eq!(header.page_number, i + 1);
2185            let expected = sample_page(u8::try_from(i).expect("fits"));
2186            assert_eq!(data, expected);
2187        }
2188
2189        // Last frame should be commit.
2190        let last_header = wal2.read_frame_header(&cx, 4).expect("read header");
2191        assert!(last_header.is_commit());
2192        assert_eq!(last_header.db_size, 5);
2193
2194        wal2.close(&cx).expect("close WAL");
2195    }
2196
2197    #[test]
2198    fn test_last_commit_frame() {
2199        let cx = test_cx();
2200        let vfs = MemoryVfs::new();
2201        let file = open_wal_file(&vfs, &cx);
2202
2203        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2204
2205        // No frames yet.
2206        assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2207
2208        // Append non-commit frame.
2209        wal.append_frame(&cx, 1, &sample_page(1), 0)
2210            .expect("append");
2211        assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2212
2213        // Append commit frame.
2214        wal.append_frame(&cx, 2, &sample_page(2), 3)
2215            .expect("append");
2216        assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(1));
2217
2218        // Append more non-commit, then another commit.
2219        wal.append_frame(&cx, 3, &sample_page(3), 0)
2220            .expect("append");
2221        wal.append_frame(&cx, 4, &sample_page(4), 5)
2222            .expect("append");
2223        assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(3));
2224
2225        wal.close(&cx).expect("close WAL");
2226    }
2227
2228    #[test]
2229    fn test_reset_clears_frames() {
2230        let cx = test_cx();
2231        let vfs = MemoryVfs::new();
2232        let file = open_wal_file(&vfs, &cx);
2233
2234        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2235
2236        // Append some frames.
2237        for i in 0..3u8 {
2238            let db_size = if i == 2 { 3 } else { 0 };
2239            wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2240                .expect("append");
2241        }
2242        assert_eq!(wal.frame_count(), 3);
2243
2244        // Reset with new salts.
2245        let new_salts = WalSalts {
2246            salt1: 0x1111_2222,
2247            salt2: 0x3333_4444,
2248        };
2249        wal.reset(&cx, 1, new_salts, true).expect("reset");
2250        assert_eq!(wal.frame_count(), 0);
2251        assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
2252        assert_eq!(wal.header().checkpoint_seq, 1);
2253        assert_eq!(wal.header().salts, new_salts);
2254
2255        // Can append new frames after reset.
2256        wal.append_frame(&cx, 10, &sample_page(0xAA), 1)
2257            .expect("append after reset");
2258        assert_eq!(wal.frame_count(), 1);
2259        assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(0));
2260
2261        wal.close(&cx).expect("close WAL");
2262
2263        // Reopen and verify reset took effect.
2264        let file2 = open_wal_file(&vfs, &cx);
2265        let wal2 = WalFile::open(&cx, file2).expect("open WAL");
2266        assert_eq!(wal2.frame_count(), 1);
2267        assert_eq!(wal2.header().checkpoint_seq, 1);
2268        assert_eq!(wal2.header().salts, new_salts);
2269
2270        wal2.close(&cx).expect("close WAL");
2271    }
2272
2273    #[test]
2274    fn test_page_size_mismatch_rejected() {
2275        let cx = test_cx();
2276        let vfs = MemoryVfs::new();
2277        let file = open_wal_file(&vfs, &cx);
2278
2279        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2280
2281        // Wrong-size page data should be rejected.
2282        let short_page = vec![0u8; 100];
2283        let result = wal.append_frame(&cx, 1, &short_page, 0);
2284        assert!(result.is_err());
2285
2286        let long_page = vec![0u8; 8192];
2287        let result = wal.append_frame(&cx, 1, &long_page, 0);
2288        assert!(result.is_err());
2289
2290        wal.close(&cx).expect("close WAL");
2291    }
2292
2293    #[test]
2294    fn test_frame_index_out_of_range() {
2295        let cx = test_cx();
2296        let vfs = MemoryVfs::new();
2297        let file = open_wal_file(&vfs, &cx);
2298
2299        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2300
2301        // Reading from empty WAL should fail.
2302        assert!(wal.read_frame(&cx, 0).is_err());
2303        assert!(wal.read_frame_header(&cx, 0).is_err());
2304
2305        // Append one frame, then reading index 1 should fail.
2306        wal.append_frame(&cx, 1, &sample_page(0), 0)
2307            .expect("append");
2308        assert!(wal.read_frame(&cx, 0).is_ok());
2309        assert!(wal.read_frame(&cx, 1).is_err());
2310
2311        wal.close(&cx).expect("close WAL");
2312    }
2313
2314    #[test]
2315    fn test_reopen_preserves_checksum_chain() {
2316        let cx = test_cx();
2317        let vfs = MemoryVfs::new();
2318        let file = open_wal_file(&vfs, &cx);
2319
2320        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2321
2322        // Write 3 frames (last is a commit so recovery sees them).
2323        for i in 0..3u8 {
2324            let db_size = if i == 2 { 3 } else { 0 };
2325            wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2326                .expect("append");
2327        }
2328        let checksum_after_3 = wal.running_checksum();
2329        wal.close(&cx).expect("close WAL");
2330
2331        // Reopen and append more frames (checksum chain must continue).
2332        let file2 = open_wal_file(&vfs, &cx);
2333        let mut wal2 = WalFile::open(&cx, file2).expect("open WAL");
2334        assert_eq!(wal2.frame_count(), 3);
2335        assert_eq!(wal2.running_checksum(), checksum_after_3);
2336
2337        wal2.append_frame(&cx, 4, &sample_page(3), 0)
2338            .expect("append");
2339        wal2.append_frame(&cx, 5, &sample_page(4), 5)
2340            .expect("append commit");
2341        assert_eq!(wal2.frame_count(), 5);
2342        wal2.close(&cx).expect("close WAL");
2343
2344        // Final reopen: all 5 frames valid.
2345        let file3 = open_wal_file(&vfs, &cx);
2346        let wal3 = WalFile::open(&cx, file3).expect("open WAL");
2347        assert_eq!(wal3.frame_count(), 5);
2348        wal3.close(&cx).expect("close WAL");
2349    }
2350
2351    #[test]
2352    fn sync_records_fsynced_frame_count_for_production_flags() {
2353        let cx = test_cx();
2354        let vfs = MemoryVfs::new();
2355        let file = open_wal_file(&vfs, &cx);
2356
2357        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2358        wal.append_frame(&cx, 1, &sample_page(0), 1)
2359            .expect("append");
2360        wal.sync(&cx, SyncFlags::NORMAL).expect("sync");
2361        assert_eq!(wal.last_fsynced_frame_count(), 1);
2362
2363        wal.append_frame(&cx, 2, &sample_page(1), 2)
2364            .expect("append");
2365        wal.sync(&cx, SyncFlags::FULL).expect("full sync");
2366        assert_eq!(wal.last_fsynced_frame_count(), 2);
2367
2368        wal.append_frame(&cx, 3, &sample_page(2), 3)
2369            .expect("append");
2370        wal.sync(&cx, SyncFlags::DATAONLY).expect("data-only sync");
2371        assert_eq!(wal.last_fsynced_frame_count(), 3);
2372
2373        wal.close(&cx).expect("close WAL");
2374    }
2375
2376    #[test]
2377    fn test_fault_hook_sync_failure_returns_error_and_records_context() {
2378        let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2379        crate::fault_hooks::clear();
2380
2381        let cx = test_cx();
2382        let vfs = MemoryVfs::new();
2383        let file = open_wal_file(&vfs, &cx);
2384        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2385        wal.append_frame(&cx, 1, &sample_page(0x44), 1)
2386            .expect("append frame");
2387        wal.sync(&cx, SyncFlags::NORMAL)
2388            .expect("establish durable watermark");
2389        assert_eq!(wal.last_fsynced_frame_count(), 1);
2390        wal.append_frame(&cx, 2, &sample_page(0x45), 2)
2391            .expect("append unsynced frame");
2392
2393        crate::fault_hooks::arm_sync_failure(crate::fault_hooks::FaultHookArm::new(
2394            "bd-db300.7.2.2-sync-failure",
2395            "WAL-SYNC-FAILURE",
2396            "wal_sync_recovery",
2397        ));
2398
2399        let error = wal
2400            .sync(&cx, SyncFlags::NORMAL)
2401            .expect_err("fault hook should force sync failure");
2402        assert!(
2403            error.to_string().contains("fault_inject:wal_sync_failure"),
2404            "fault error should identify the sync hook: {error}"
2405        );
2406        assert_eq!(
2407            wal.last_fsynced_frame_count(),
2408            1,
2409            "a failed sync must preserve the prior durable-barrier accounting"
2410        );
2411
2412        let records = crate::fault_hooks::take_records();
2413        assert_eq!(
2414            records.len(),
2415            1,
2416            "exactly one sync fault should be recorded"
2417        );
2418        assert_eq!(records[0].point, "wal_sync_failure");
2419        assert_eq!(records[0].run_id, "bd-db300.7.2.2-sync-failure");
2420        assert!(
2421            records[0].detail.contains("frame_count_before=2"),
2422            "record should capture sync context: {}",
2423            records[0].detail
2424        );
2425
2426        crate::fault_hooks::clear();
2427    }
2428
2429    #[test]
2430    fn test_fault_hook_append_busy_countdown_fires_once_and_preserves_retry_surface() {
2431        let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2432        crate::fault_hooks::clear();
2433
2434        let cx = test_cx();
2435        let vfs = MemoryVfs::new();
2436        let file = open_wal_file(&vfs, &cx);
2437        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
2438
2439        crate::fault_hooks::arm_append_busy_countdown(
2440            crate::fault_hooks::FaultHookArm::new(
2441                "bd-db300.7.2.2-busy-countdown",
2442                "WAL-APPEND-BUSY",
2443                "wal_append_retry",
2444            ),
2445            2,
2446        );
2447
2448        let first_page = sample_page(0x55);
2449        let first_frames = [WalAppendFrameRef {
2450            page_number: 1,
2451            page_data: &first_page,
2452            db_size_if_commit: 1,
2453        }];
2454        wal.append_frames(&cx, &first_frames)
2455            .expect("countdown should not fire on first append");
2456
2457        let second_page = sample_page(0x66);
2458        let second_frames = [WalAppendFrameRef {
2459            page_number: 2,
2460            page_data: &second_page,
2461            db_size_if_commit: 2,
2462        }];
2463        let busy = wal
2464            .append_frames(&cx, &second_frames)
2465            .expect_err("countdown should fire on second append");
2466        assert!(matches!(busy, FrankenError::Busy));
2467        assert_eq!(
2468            wal.frame_count(),
2469            1,
2470            "busy fault should fire before the second append mutates WAL state"
2471        );
2472
2473        wal.append_frames(&cx, &second_frames)
2474            .expect("hook should disarm after firing once");
2475        assert_eq!(
2476            wal.frame_count(),
2477            2,
2478            "retry should succeed once the hook is spent"
2479        );
2480
2481        let records = crate::fault_hooks::take_records();
2482        assert_eq!(records.len(), 1, "busy countdown should record one trigger");
2483        assert_eq!(records[0].point, "wal_append_busy_countdown");
2484        assert_eq!(records[0].run_id, "bd-db300.7.2.2-busy-countdown");
2485        assert!(
2486            records[0].detail.contains("submitted_frames=1"),
2487            "record should preserve append batch context: {}",
2488            records[0].detail
2489        );
2490
2491        crate::fault_hooks::clear();
2492    }
2493
2494    /// H9 / F9: Crash between WAL header rewrite (new salts) and truncation.
2495    ///
2496    /// After injection, the WAL file has:
2497    /// - New header with new salts (written and synced)
2498    /// - Old frames with OLD salts (not yet truncated)
2499    ///
2500    /// Recovery (WalFile::open) must see the salt mismatch between header
2501    /// and frames, and discard ALL frames. Result: frame_count == 0.
2502    ///
2503    /// Replay: `cargo test -p fsqlite-wal -- test_fault_crash_between_header_and_truncate --nocapture`
2504    #[test]
2505    fn test_fault_crash_between_header_and_truncate_recovers_to_zero_frames() {
2506        let _guard = FAULT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2507        crate::fault_hooks::clear();
2508
2509        let cx = test_cx();
2510        let vfs = MemoryVfs::new();
2511        let file = open_wal_file(&vfs, &cx);
2512        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2513
2514        // Append 3 frames with the original salts.
2515        for i in 1..=3_u32 {
2516            let page = sample_page(i as u8);
2517            wal.append_frame(&cx, i, &page, i)
2518                .expect("append frame before reset fault injection");
2519        }
2520        wal.sync(&cx, SyncFlags::NORMAL).expect("sync WAL");
2521        assert_eq!(wal.frame_count(), 3, "pre-reset: 3 frames");
2522
2523        let original_salts = wal.generation_identity().salts;
2524
2525        // Arm the crash-header-truncate hook.
2526        crate::fault_hooks::arm_crash_header_truncate(crate::fault_hooks::FaultHookArm::new(
2527            "bd-db300.7.2.2-h9",
2528            "WAL-CRASH-HEADER-TRUNCATE",
2529            "wal_reset_recovery",
2530        ));
2531
2532        // Attempt reset — should fail after writing new header but before truncation.
2533        let new_salts = WalSalts {
2534            salt1: original_salts.salt1.wrapping_add(1),
2535            salt2: original_salts.salt2.wrapping_add(1),
2536        };
2537        let err = wal
2538            .reset(&cx, 1, new_salts, true)
2539            .expect_err("fault hook should fire between header write and truncate");
2540        assert!(
2541            err.to_string()
2542                .contains("fault_inject:wal_crash_header_truncate"),
2543            "error should identify the hook: {err}"
2544        );
2545
2546        // The WAL is now in a corrupted state:
2547        // - Header has new salts (written and synced before hook fired)
2548        // - Frames still have old salts (truncation was prevented)
2549        // Close the handle without further I/O.
2550        wal.close(&cx).expect("close WAL handle");
2551
2552        // Recovery: re-open the WAL.
2553        let recovered_file = open_wal_file(&vfs, &cx);
2554        let recovered = WalFile::open(&cx, recovered_file).expect("reopen WAL");
2555
2556        // Proof obligation: frame_count == 0 because all old frames have
2557        // mismatched salts vs the new header.
2558        assert_eq!(
2559            recovered.frame_count(),
2560            0,
2561            "recovery must discard all old-salt frames after header rewrite"
2562        );
2563        assert_eq!(
2564            recovered.generation_identity().salts,
2565            new_salts,
2566            "recovered header must have the new salts"
2567        );
2568
2569        // Verify injection record.
2570        let records = crate::fault_hooks::take_records();
2571        assert_eq!(records.len(), 1, "exactly one crash hook should fire");
2572        assert_eq!(records[0].point, "wal_crash_header_truncate");
2573        assert_eq!(records[0].scenario_id, "WAL-CRASH-HEADER-TRUNCATE");
2574        assert!(
2575            records[0].detail.contains("old_frame_count=3"),
2576            "record should capture pre-reset frame count: {}",
2577            records[0].detail
2578        );
2579        assert!(
2580            records[0].detail.contains("new_checkpoint_seq=1"),
2581            "record should capture checkpoint seq: {}",
2582            records[0].detail
2583        );
2584
2585        crate::fault_hooks::clear();
2586    }
2587
2588    #[test]
2589    fn test_file_accessors() {
2590        let cx = test_cx();
2591        let vfs = MemoryVfs::new();
2592        let file = open_wal_file(&vfs, &cx);
2593
2594        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2595
2596        // file() and file_mut() should work without panic.
2597        let _size = wal.file().file_size(&cx).expect("file_size");
2598        let _size = wal.file_mut().file_size(&cx).expect("file_size via mut");
2599
2600        wal.close(&cx).expect("close WAL");
2601    }
2602
2603    // ── bd-14m.4: WAL crash recovery tests ──
2604
2605    #[test]
2606    fn test_truncated_wal_recovers_committed_prefix() {
2607        // Simulate a crash mid-write by truncating the WAL file after the 3rd
2608        // frame (of 5). On reopen, only the committed prefix should load.
2609        // Frame 3 (i==2) is a commit; frame 5 (i==4) is also a commit but gets truncated.
2610        let cx = test_cx();
2611        let vfs = MemoryVfs::new();
2612        let file = open_wal_file(&vfs, &cx);
2613
2614        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2615        for i in 0..5u8 {
2616            let db_size = if i == 2 {
2617                3
2618            } else if i == 4 {
2619                5
2620            } else {
2621                0
2622            };
2623            wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2624                .expect("append");
2625        }
2626        assert_eq!(wal.frame_count(), 5);
2627
2628        // Get file handle for raw truncation.
2629        let frame_size = wal.frame_size();
2630        // Truncate mid-way through frame 4 (keep header + 3 complete frames + partial 4th).
2631        let truncate_at = WAL_HEADER_SIZE + frame_size * 3 + frame_size / 2;
2632        let truncate_at_u64 = u64::try_from(truncate_at).expect("truncate_at fits u64");
2633        wal.file_mut()
2634            .truncate(&cx, truncate_at_u64)
2635            .expect("truncate");
2636        wal.close(&cx).expect("close WAL");
2637
2638        // Reopen: only the 3 fully-written frames should be recovered.
2639        let file2 = open_wal_file(&vfs, &cx);
2640        let wal2 = WalFile::open(&cx, file2).expect("open WAL after truncation");
2641        assert_eq!(
2642            wal2.frame_count(),
2643            3,
2644            "only the 3 complete frames before truncation should survive"
2645        );
2646
2647        // Verify data integrity of the surviving frames.
2648        for i in 0..3u8 {
2649            let (header, data) = wal2.read_frame(&cx, usize::from(i)).expect("read frame");
2650            assert_eq!(header.page_number, u32::from(i) + 1);
2651            assert_eq!(data, sample_page(i));
2652        }
2653        wal2.close(&cx).expect("close WAL");
2654    }
2655
2656    #[test]
2657    fn test_corrupt_frame_payload_detected_on_reopen() {
2658        // Corrupt a byte in frame 3's payload. On reopen, the checksum chain
2659        // breaks at frame 3, so only the committed prefix (frames 0-2) should load.
2660        // Frame 3 (i==2) is a commit marker so the committed prefix is 3 frames.
2661        let cx = test_cx();
2662        let vfs = MemoryVfs::new();
2663        let file = open_wal_file(&vfs, &cx);
2664
2665        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2666        for i in 0..5u8 {
2667            let db_size = if i == 2 {
2668                3
2669            } else if i == 4 {
2670                5
2671            } else {
2672                0
2673            };
2674            wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
2675                .expect("append");
2676        }
2677        let frame_size = wal.frame_size();
2678        wal.close(&cx).expect("close WAL");
2679
2680        // Corrupt one byte in frame 3's page data.
2681        let corrupt_offset = WAL_HEADER_SIZE + frame_size * 3 + WAL_FRAME_HEADER_SIZE + 42;
2682        let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
2683        let f = open_wal_file(&vfs, &cx);
2684        let mut buf = [0u8; 1];
2685        f.read(&cx, &mut buf, corrupt_offset_u64)
2686            .expect("read byte");
2687        buf[0] ^= 0xFF;
2688        f.write(&cx, &buf, corrupt_offset_u64)
2689            .expect("write corrupted byte");
2690        drop(f);
2691
2692        // Reopen: checksum chain should break at frame 3.
2693        let file3 = open_wal_file(&vfs, &cx);
2694        let wal3 = WalFile::open(&cx, file3).expect("open WAL after corruption");
2695        assert_eq!(
2696            wal3.frame_count(),
2697            3,
2698            "frames after corruption point should be discarded"
2699        );
2700        wal3.close(&cx).expect("close WAL");
2701    }
2702
2703    #[test]
2704    fn test_multi_commit_recovery_to_last_valid() {
2705        // Write two transactions (commit at frame 3, commit at frame 6).
2706        // Corrupt frame 5, so recovery should yield 4 valid frames (up to
2707        // the break at frame 5). The last valid commit is frame 3.
2708        let cx = test_cx();
2709        let vfs = MemoryVfs::new();
2710        let file = open_wal_file(&vfs, &cx);
2711
2712        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2713
2714        // Transaction 1: frames 1-3, commit on frame 3.
2715        for i in 1..=3u32 {
2716            let db_size = if i == 3 { 3 } else { 0 };
2717            wal.append_frame(
2718                &cx,
2719                i,
2720                &sample_page(u8::try_from(i).expect("i fits u8")),
2721                db_size,
2722            )
2723            .expect("append");
2724        }
2725
2726        // Transaction 2: frames 4-6, commit on frame 6.
2727        for i in 4..=6u32 {
2728            let db_size = if i == 6 { 6 } else { 0 };
2729            wal.append_frame(
2730                &cx,
2731                i,
2732                &sample_page(u8::try_from(i).expect("i fits u8")),
2733                db_size,
2734            )
2735            .expect("append");
2736        }
2737        assert_eq!(wal.frame_count(), 6);
2738        let frame_size = wal.frame_size();
2739        wal.close(&cx).expect("close WAL");
2740
2741        // Corrupt frame 5 (index 4) payload.
2742        let corrupt_offset = WAL_HEADER_SIZE + frame_size * 4 + WAL_FRAME_HEADER_SIZE + 10;
2743        let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
2744        let f = open_wal_file(&vfs, &cx);
2745        let mut buf = [0u8; 1];
2746        f.read(&cx, &mut buf, corrupt_offset_u64).expect("read");
2747        buf[0] ^= 0xAA;
2748        f.write(&cx, &buf, corrupt_offset_u64).expect("corrupt");
2749        drop(f);
2750
2751        // Reopen: chain breaks at frame 5 (index 4). The last commit
2752        // boundary is frame 3 (db_size=3), so only 3 committed frames remain.
2753        let file2 = open_wal_file(&vfs, &cx);
2754        let wal2 = WalFile::open(&cx, file2).expect("open WAL after corruption");
2755        assert_eq!(
2756            wal2.frame_count(),
2757            3,
2758            "chain should break at corrupted frame 5, keeping committed prefix (frames 1-3)"
2759        );
2760
2761        // The last commit frame is frame 3 (db_size=3).
2762        let header3 = wal2.read_frame_header(&cx, 2).expect("read frame 3 header");
2763        assert!(header3.is_commit(), "frame 3 should be a commit frame");
2764
2765        wal2.close(&cx).expect("close WAL");
2766    }
2767
2768    #[test]
2769    fn test_wal_growth_bounded_by_restart_checkpoint() {
2770        // Verify that a Restart checkpoint resets WAL to 0 frames,
2771        // preventing unbounded growth.
2772        use crate::checkpoint::{CheckpointMode, CheckpointState};
2773        use crate::checkpoint_executor::execute_checkpoint;
2774        use crate::checkpoint_executor::{CheckpointTarget, CheckpointTargetFuture};
2775        use fsqlite_types::PageNumber;
2776
2777        struct DummyTarget;
2778        impl CheckpointTarget for DummyTarget {
2779            fn write_page<'a>(
2780                &'a mut self,
2781                _: &'a Cx,
2782                _: PageNumber,
2783                _: &'a [u8],
2784            ) -> CheckpointTargetFuture<'a, ()> {
2785                Box::pin(async { Ok(()) })
2786            }
2787            fn truncate_db<'a>(&'a mut self, _: &'a Cx, _: u32) -> CheckpointTargetFuture<'a, ()> {
2788                Box::pin(async { Ok(()) })
2789            }
2790            fn sync_db<'a>(&'a mut self, _: &'a Cx) -> CheckpointTargetFuture<'a, ()> {
2791                Box::pin(async { Ok(()) })
2792            }
2793        }
2794
2795        let cx = test_cx();
2796        let vfs = MemoryVfs::new();
2797        let file = open_wal_file(&vfs, &cx);
2798        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2799
2800        // Write 100 frames (simulating many transactions).
2801        for i in 1..=100u32 {
2802            let seed = u8::try_from(i % 256).expect("seed fits u8");
2803            let db_size = if i % 10 == 0 { i } else { 0 };
2804            wal.append_frame(&cx, (i - 1) % 50 + 1, &sample_page(seed), db_size)
2805                .expect("append");
2806        }
2807        assert_eq!(wal.frame_count(), 100);
2808
2809        // Restart checkpoint: backfill all + reset.
2810        let state = CheckpointState {
2811            total_frames: 100,
2812            backfilled_frames: 0,
2813            oldest_reader_frame: None,
2814        };
2815        let mut target = DummyTarget;
2816        let result = execute_checkpoint(&cx, &mut wal, CheckpointMode::Restart, state, &mut target)
2817            .expect("restart checkpoint");
2818
2819        assert_eq!(result.frames_backfilled, 100);
2820        assert!(result.wal_was_reset);
2821        assert_eq!(wal.frame_count(), 0, "WAL should be empty after restart");
2822
2823        // Write new frames after reset: WAL accepts them.
2824        wal.append_frame(&cx, 1, &sample_page(0xAA), 1)
2825            .expect("append after reset");
2826        assert_eq!(wal.frame_count(), 1);
2827        assert_eq!(wal.header().checkpoint_seq, 1, "checkpoint_seq incremented");
2828
2829        wal.close(&cx).expect("close WAL");
2830    }
2831
2832    #[test]
2833    fn test_wal_header_corruption_detected() {
2834        // Corrupt the WAL header magic bytes. Open should fail or return
2835        // an error since the header is invalid.
2836        let cx = test_cx();
2837        let vfs = MemoryVfs::new();
2838        let file = open_wal_file(&vfs, &cx);
2839
2840        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2841        wal.append_frame(&cx, 1, &sample_page(1), 1)
2842            .expect("append");
2843        wal.close(&cx).expect("close WAL");
2844
2845        // Corrupt the magic bytes at offset 0.
2846        let f = open_wal_file(&vfs, &cx);
2847        let corrupted_magic = [0xFF, 0xFF, 0xFF, 0xFF];
2848        f.write(&cx, &corrupted_magic, 0).expect("corrupt header");
2849        drop(f);
2850
2851        // Attempt to reopen: should error due to bad magic.
2852        let file2 = open_wal_file(&vfs, &cx);
2853        let result = WalFile::open(&cx, file2);
2854        assert!(
2855            result.is_err(),
2856            "opening WAL with corrupted header magic should fail"
2857        );
2858    }
2859
2860    #[test]
2861    fn test_empty_wal_after_crash_reopen() {
2862        // Create a WAL, close it before writing any frames.
2863        // Reopen should succeed with 0 frames (clean state).
2864        let cx = test_cx();
2865        let vfs = MemoryVfs::new();
2866        let file = open_wal_file(&vfs, &cx);
2867
2868        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2869        wal.close(&cx).expect("close WAL");
2870
2871        let file2 = open_wal_file(&vfs, &cx);
2872        let wal2 = WalFile::open(&cx, file2).expect("reopen empty WAL");
2873        assert_eq!(wal2.frame_count(), 0);
2874        wal2.close(&cx).expect("close WAL");
2875    }
2876
2877    #[test]
2878    fn test_crash_after_single_uncommitted_frame() {
2879        // Write a single non-commit frame (db_size=0), close/reopen.
2880        // Since this frame is not a commit, recovery correctly excludes it
2881        // from the committed frame count. Only committed transactions are
2882        // visible after WAL recovery.
2883        let cx = test_cx();
2884        let vfs = MemoryVfs::new();
2885        let file = open_wal_file(&vfs, &cx);
2886
2887        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2888        wal.append_frame(&cx, 1, &sample_page(0x77), 0)
2889            .expect("append non-commit");
2890        wal.close(&cx).expect("close WAL");
2891
2892        let file2 = open_wal_file(&vfs, &cx);
2893        let wal2 = WalFile::open(&cx, file2).expect("reopen WAL");
2894        assert_eq!(
2895            wal2.frame_count(),
2896            0,
2897            "uncommitted frame excluded from recovery"
2898        );
2899        wal2.close(&cx).expect("close WAL");
2900    }
2901
2902    #[test]
2903    fn test_frame_offset_calculation_overflow_safety() {
2904        // This test ensures that the frame offset calculation logic doesn't overflow on 32-bit systems
2905        // by verifying it uses u64 arithmetic.
2906
2907        let page_size: u64 = 4096;
2908        let wal_header_size: u64 = 32;
2909        let wal_frame_header_size: u64 = 24;
2910        let frame_size = wal_frame_header_size + page_size;
2911
2912        // An index that would overflow if multiplied by frame_size in u32/usize(32-bit).
2913        // u32::MAX is 4,294,967,295.
2914        // frame_size is 4120.
2915        // 4,294,967,295 / 4120 = 1,042,467.
2916        // So index 1,042,468 causes overflow in 32-bit if not cast to u64.
2917        let large_index: u64 = 1_042_468;
2918
2919        let idx_u64 = large_index;
2920        let expected_offset = wal_header_size + idx_u64 * frame_size;
2921
2922        // Replicate logic from WalFile::frame_offset
2923        let calculated_offset = wal_header_size + idx_u64 * frame_size;
2924
2925        assert_eq!(calculated_offset, expected_offset);
2926
2927        // We can't easily instantiate a WalFile with this many frames without massive I/O,
2928        // but we've verified the arithmetic logic in the test body matches the implementation.
2929    }
2930
2931    // ── bd-xfn30.1: WAL append path correctness ──
2932    //
2933    // Frame ordering, checksum determinism, commit boundary semantics.
2934
2935    #[test]
2936    fn test_frame_offsets_sequential_no_gaps() {
2937        // Verify that file offsets match the expected formula:
2938        //   offset(i) = WAL_HEADER_SIZE + i * frame_size
2939        let cx = test_cx();
2940        let vfs = MemoryVfs::new();
2941        let file = open_wal_file(&vfs, &cx);
2942
2943        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2944
2945        let n = 20u32;
2946        for i in 0..n {
2947            let db_size = if i == n - 1 { n } else { 0 };
2948            wal.append_frame(
2949                &cx,
2950                i + 1,
2951                &sample_page(u8::try_from(i % 251).unwrap()),
2952                db_size,
2953            )
2954            .expect("append");
2955        }
2956
2957        let frame_size = wal.frame_size();
2958        let file_size = wal.file().file_size(&cx).expect("file_size");
2959        let expected_size =
2960            u64::try_from(WAL_HEADER_SIZE + usize::try_from(n).unwrap() * frame_size).unwrap();
2961        assert_eq!(
2962            file_size, expected_size,
2963            "WAL file size must equal header + n*frame_size with no padding or gaps"
2964        );
2965
2966        // Verify each frame header's page_number at the right offset.
2967        for i in 0..n {
2968            let header = wal
2969                .read_frame_header(&cx, usize::try_from(i).unwrap())
2970                .expect("read header");
2971            assert_eq!(header.page_number, i + 1, "frame {i} page_number");
2972        }
2973
2974        wal.close(&cx).expect("close WAL");
2975    }
2976
2977    #[test]
2978    fn test_checksum_determinism_same_input() {
2979        // Two separate WALs created with identical params and identical frames
2980        // must produce byte-for-byte identical checksum chains.
2981        let cx = test_cx();
2982        let vfs1 = MemoryVfs::new();
2983        let vfs2 = MemoryVfs::new();
2984
2985        let mut checksums_a = Vec::new();
2986        let mut checksums_b = Vec::new();
2987
2988        for (vfs, checksums) in [(&vfs1, &mut checksums_a), (&vfs2, &mut checksums_b)] {
2989            let file = open_wal_file(vfs, &cx);
2990            let mut wal =
2991                WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
2992
2993            for i in 0..10u8 {
2994                let page = sample_page(i);
2995                let db_size = if i == 9 { 10 } else { 0 };
2996                wal.append_frame(&cx, u32::from(i) + 1, &page, db_size)
2997                    .expect("append");
2998                checksums.push(wal.running_checksum());
2999            }
3000            wal.close(&cx).expect("close WAL");
3001        }
3002
3003        assert_eq!(
3004            checksums_a, checksums_b,
3005            "identical inputs must produce identical checksum chains"
3006        );
3007    }
3008
3009    #[test]
3010    fn test_checksum_sensitivity_one_byte_difference() {
3011        // Changing one byte in one frame's page data must produce a different
3012        // running checksum from that frame onward.
3013        let cx = test_cx();
3014        let vfs1 = MemoryVfs::new();
3015        let vfs2 = MemoryVfs::new();
3016
3017        let mut checksums_a = Vec::new();
3018        let mut checksums_b = Vec::new();
3019
3020        let file1 = open_wal_file(&vfs1, &cx);
3021        let mut wal1 = WalFile::create(&cx, file1, PAGE_SIZE, 0, test_salts()).expect("create");
3022        let file2 = open_wal_file(&vfs2, &cx);
3023        let mut wal2 = WalFile::create(&cx, file2, PAGE_SIZE, 0, test_salts()).expect("create");
3024
3025        for i in 0..5u8 {
3026            let mut page = sample_page(i);
3027            let db_size = if i == 4 { 5 } else { 0 };
3028            wal1.append_frame(&cx, u32::from(i) + 1, &page, db_size)
3029                .expect("append");
3030            checksums_a.push(wal1.running_checksum());
3031
3032            // Flip one byte in frame 2 only.
3033            if i == 2 {
3034                page[0] ^= 0x01;
3035            }
3036            wal2.append_frame(&cx, u32::from(i) + 1, &page, db_size)
3037                .expect("append");
3038            checksums_b.push(wal2.running_checksum());
3039        }
3040
3041        // Frames 0..2 should match, frames 2..5 should diverge.
3042        assert_eq!(checksums_a[0], checksums_b[0], "frame 0 should match");
3043        assert_eq!(checksums_a[1], checksums_b[1], "frame 1 should match");
3044        assert_ne!(checksums_a[2], checksums_b[2], "frame 2 must diverge");
3045        assert_ne!(checksums_a[3], checksums_b[3], "frame 3 must diverge");
3046        assert_ne!(checksums_a[4], checksums_b[4], "frame 4 must diverge");
3047
3048        wal1.close(&cx).expect("close");
3049        wal2.close(&cx).expect("close");
3050    }
3051
3052    #[test]
3053    fn test_commit_boundary_every_frame() {
3054        // All frames are commit frames (db_size > 0).
3055        // Recovery should see all frames after reopen.
3056        let cx = test_cx();
3057        let vfs = MemoryVfs::new();
3058        let file = open_wal_file(&vfs, &cx);
3059
3060        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3061
3062        let n = 8u32;
3063        for i in 0..n {
3064            wal.append_frame(&cx, i + 1, &sample_page(u8::try_from(i).unwrap()), i + 1)
3065                .expect("append");
3066        }
3067        assert_eq!(wal.frame_count(), usize::try_from(n).unwrap());
3068        wal.close(&cx).expect("close");
3069
3070        let file2 = open_wal_file(&vfs, &cx);
3071        let mut wal2 = WalFile::open(&cx, file2).expect("reopen");
3072        assert_eq!(
3073            wal2.frame_count(),
3074            usize::try_from(n).unwrap(),
3075            "all frames are commits so all should survive reopen"
3076        );
3077
3078        // Every frame should have is_commit() == true.
3079        for i in 0..n {
3080            let h = wal2
3081                .read_frame_header(&cx, usize::try_from(i).unwrap())
3082                .expect("read");
3083            assert!(h.is_commit(), "frame {i} must be a commit");
3084            assert_eq!(h.db_size, i + 1);
3085        }
3086
3087        // last_commit_frame should be the final frame.
3088        let last = wal2.last_commit_frame(&cx).expect("query");
3089        assert_eq!(last, Some(usize::try_from(n - 1).unwrap()));
3090
3091        wal2.close(&cx).expect("close");
3092    }
3093
3094    #[test]
3095    fn test_commit_boundary_interleaved_multi_txn() {
3096        // Three transactions with interleaved commit markers:
3097        //   Txn1: pages 1,2,3 (commit at frame 3, db_size=3)
3098        //   Txn2: pages 4,5 (commit at frame 5, db_size=5)
3099        //   Txn3: pages 6 (commit at frame 6, db_size=6)
3100        let cx = test_cx();
3101        let vfs = MemoryVfs::new();
3102        let file = open_wal_file(&vfs, &cx);
3103        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3104
3105        let frames: [(u32, u32); 6] = [
3106            (1, 0),
3107            (2, 0),
3108            (3, 3), // commit txn1
3109            (4, 0),
3110            (5, 5), // commit txn2
3111            (6, 6), // commit txn3
3112        ];
3113
3114        for (pg, db_sz) in frames {
3115            wal.append_frame(&cx, pg, &sample_page(u8::try_from(pg).unwrap()), db_sz)
3116                .expect("append");
3117        }
3118        assert_eq!(wal.frame_count(), 6);
3119        wal.close(&cx).expect("close");
3120
3121        // Reopen and verify all frames are valid (checksum chain intact).
3122        let file2 = open_wal_file(&vfs, &cx);
3123        let mut wal2 = WalFile::open(&cx, file2).expect("reopen");
3124        assert_eq!(wal2.frame_count(), 6);
3125
3126        // Verify each frame's content.
3127        for i in 0..6u32 {
3128            let (header, data) = wal2
3129                .read_frame(&cx, usize::try_from(i).unwrap())
3130                .expect("read frame");
3131            assert_eq!(header.page_number, i + 1);
3132            let expected = sample_page(u8::try_from(i + 1).expect("fits"));
3133            assert_eq!(data, expected);
3134        }
3135
3136        // Last frame should be commit.
3137        let last_header = wal2.read_frame_header(&cx, 5).expect("read header");
3138        assert!(last_header.is_commit());
3139        assert_eq!(last_header.db_size, 6);
3140        let last = wal2.last_commit_frame(&cx).expect("query");
3141        assert_eq!(last, Some(5), "last commit is frame 6 (index 5)");
3142
3143        wal2.close(&cx).expect("close");
3144    }
3145
3146    #[test]
3147    fn test_same_page_overwritten_multiple_times() {
3148        // Write the same page number multiple times. The WAL should record
3149        // each write at a sequential frame index. The last write's data
3150        // should be readable.
3151        let cx = test_cx();
3152        let vfs = MemoryVfs::new();
3153        let file = open_wal_file(&vfs, &cx);
3154        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3155
3156        let page_num = 42u32;
3157        let versions = 5;
3158        for v in 0..versions {
3159            let db_size = if v == versions - 1 { 100 } else { 0 };
3160            wal.append_frame(&cx, page_num, &sample_page(v), db_size)
3161                .expect("append");
3162        }
3163        assert_eq!(wal.frame_count(), usize::from(versions));
3164
3165        // Each frame should contain its unique version of the page.
3166        for v in 0..versions {
3167            let (header, data) = wal.read_frame(&cx, usize::from(v)).expect("read frame");
3168            assert_eq!(header.page_number, page_num);
3169            assert_eq!(data, sample_page(v), "frame {v} data mismatch");
3170        }
3171
3172        wal.close(&cx).expect("close");
3173    }
3174
3175    #[test]
3176    fn test_refresh_detects_concurrent_append() {
3177        // Simulate a second writer appending frames that the first handle
3178        // doesn't know about. After refresh(), the first handle should see them.
3179        let cx = test_cx();
3180        let vfs = MemoryVfs::new();
3181        let file1 = open_wal_file(&vfs, &cx);
3182        let mut wal1 = WalFile::create(&cx, file1, PAGE_SIZE, 0, test_salts()).expect("create");
3183
3184        // First writer commits 3 frames.
3185        for i in 0..3u8 {
3186            let db_size = if i == 2 { 3 } else { 0 };
3187            wal1.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
3188                .expect("append");
3189        }
3190        let checksum_after_3 = wal1.running_checksum();
3191        wal1.close(&cx).expect("close wal1");
3192
3193        // "Reader" opens, sees 3 frames.
3194        let file_reader = open_wal_file(&vfs, &cx);
3195        let mut reader = WalFile::open(&cx, file_reader).expect("open reader");
3196        assert_eq!(reader.frame_count(), 3);
3197        assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(2));
3198        reader.sync(&cx, SyncFlags::NORMAL).expect("sync reader");
3199        assert_eq!(reader.last_fsynced_frame_count(), 3);
3200
3201        // "Second writer" appends 2 more frames (frames 4,5 with commit at 5).
3202        let file_w2 = open_wal_file(&vfs, &cx);
3203        let mut w2 = WalFile::open(&cx, file_w2).expect("open w2");
3204        assert_eq!(w2.running_checksum(), checksum_after_3);
3205        w2.append_frame(&cx, 4, &sample_page(3), 0).expect("append");
3206        w2.append_frame(&cx, 5, &sample_page(4), 5)
3207            .expect("append commit");
3208        assert_eq!(w2.frame_count(), 5);
3209        w2.close(&cx).expect("close w2");
3210
3211        // Reader still sees 3 until refresh().
3212        assert_eq!(reader.frame_count(), 3);
3213        reader.refresh(&cx).expect("refresh");
3214        assert_eq!(
3215            reader.frame_count(),
3216            5,
3217            "after refresh, reader must see the 2 new committed frames"
3218        );
3219        assert_eq!(
3220            reader.last_fsynced_frame_count(),
3221            3,
3222            "same-generation incremental refresh preserves only the known watermark"
3223        );
3224        assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(4));
3225
3226        reader.close(&cx).expect("close reader");
3227    }
3228
3229    #[test]
3230    fn refresh_after_reset_clears_stale_durable_watermark() {
3231        // After a checkpoint reset, refresh should detect the salt change
3232        // and rebuild state.
3233        let cx = test_cx();
3234        let vfs = MemoryVfs::new();
3235        let file = open_wal_file(&vfs, &cx);
3236        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3237
3238        // Write and commit.
3239        wal.append_frame(&cx, 1, &sample_page(1), 1)
3240            .expect("append");
3241        wal.close(&cx).expect("close");
3242
3243        // Open as "reader".
3244        let file_r = open_wal_file(&vfs, &cx);
3245        let mut reader = WalFile::open(&cx, file_r).expect("open reader");
3246        assert_eq!(reader.frame_count(), 1);
3247        assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(0));
3248        reader
3249            .sync(&cx, SyncFlags::NORMAL)
3250            .expect("sync old generation");
3251        assert_eq!(reader.last_fsynced_frame_count(), 1);
3252
3253        // "Checkpointer" opens, resets with new salts.
3254        let file_cp = open_wal_file(&vfs, &cx);
3255        let mut cp = WalFile::open(&cx, file_cp).expect("open cp");
3256        let new_salts = WalSalts {
3257            salt1: 0xAAAA_BBBB,
3258            salt2: 0xCCCC_DDDD,
3259        };
3260        cp.reset(&cx, 1, new_salts, false).expect("reset");
3261        cp.append_frame(&cx, 1, &sample_page(0xAA), 1)
3262            .expect("append after reset");
3263        cp.close(&cx).expect("close cp");
3264
3265        // Reader refresh: should rebuild and see the new generation.
3266        reader.refresh(&cx).expect("refresh");
3267        assert_eq!(reader.frame_count(), 1);
3268        assert_eq!(
3269            reader.last_fsynced_frame_count(),
3270            0,
3271            "a new generation's unsynced frame must not inherit the old generation watermark"
3272        );
3273        assert_eq!(reader.last_commit_frame(&cx).expect("query"), Some(0));
3274        assert_eq!(
3275            reader.header().salts,
3276            new_salts,
3277            "salts should be new generation"
3278        );
3279
3280        reader.close(&cx).expect("close reader");
3281    }
3282
3283    #[test]
3284    fn test_refresh_after_reset_with_same_salts_detects_new_generation() {
3285        // Generation identity must include checkpoint_seq, not just salts.
3286        // Otherwise a reset that reuses the same salts becomes an ABA hazard.
3287        let cx = test_cx();
3288        let vfs = MemoryVfs::new();
3289        let file = open_wal_file(&vfs, &cx);
3290        let salts = test_salts();
3291        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, salts).expect("create");
3292
3293        wal.append_frame(&cx, 1, &sample_page(1), 1)
3294            .expect("append");
3295        wal.close(&cx).expect("close");
3296
3297        let file_r = open_wal_file(&vfs, &cx);
3298        let mut reader = WalFile::open(&cx, file_r).expect("open reader");
3299        let before = reader.generation_identity();
3300        assert_eq!(before.checkpoint_seq, 0);
3301        assert_eq!(before.salts, salts);
3302        assert_eq!(reader.frame_count(), 1);
3303
3304        let file_cp = open_wal_file(&vfs, &cx);
3305        let mut cp = WalFile::open(&cx, file_cp).expect("open cp");
3306        cp.reset(&cx, 1, salts, false)
3307            .expect("reset with same salts");
3308        cp.append_frame(&cx, 2, &sample_page(0xAA), 2)
3309            .expect("append after reset");
3310        cp.close(&cx).expect("close cp");
3311
3312        reader.refresh(&cx).expect("refresh");
3313        let after = reader.generation_identity();
3314        assert_eq!(
3315            after.checkpoint_seq, 1,
3316            "refresh must observe new checkpoint_seq"
3317        );
3318        assert_eq!(
3319            after.salts, salts,
3320            "same-salt reset is intentional in this test"
3321        );
3322        assert_ne!(
3323            before, after,
3324            "generation identity must change even when salts are reused"
3325        );
3326        assert_eq!(
3327            reader.frame_count(),
3328            1,
3329            "reader must rebuild to new generation"
3330        );
3331
3332        let (header, data) = reader.read_frame(&cx, 0).expect("read rebuilt frame");
3333        assert_eq!(
3334            header.page_number, 2,
3335            "reader must see new-generation frame"
3336        );
3337        assert_eq!(data, sample_page(0xAA));
3338
3339        reader.close(&cx).expect("close reader");
3340    }
3341
3342    #[test]
3343    fn test_group_commit_checksum_chain_matches_single_append() {
3344        // Verify that writing frames via group commit produces the exact same
3345        // checksum chain as writing them one-at-a-time via append_frame().
3346        use crate::group_commit::{
3347            FrameSubmission, TransactionFrameBatch, write_consolidated_frames,
3348        };
3349
3350        let cx = test_cx();
3351        let vfs_single = MemoryVfs::new();
3352        let vfs_group = MemoryVfs::new();
3353
3354        let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3355        let page_nums: Vec<u32> = (1..=6u32).collect();
3356        // Commit at frame 3 and frame 6.
3357        let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3358
3359        // Single-frame path.
3360        let file_s = open_wal_file(&vfs_single, &cx);
3361        let mut wal_s =
3362            WalFile::create(&cx, file_s, PAGE_SIZE, 0, test_salts()).expect("create single");
3363        for i in 0..6 {
3364            wal_s
3365                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3366                .expect("append single");
3367        }
3368        let single_checksum = wal_s.running_checksum();
3369        let single_count = wal_s.frame_count();
3370
3371        // Group commit path: two batches of 3 frames each.
3372        let file_g = open_wal_file(&vfs_group, &cx);
3373        let mut wal_g =
3374            WalFile::create(&cx, file_g, PAGE_SIZE, 0, test_salts()).expect("create group");
3375
3376        let batch1 = TransactionFrameBatch::new(
3377            (0..3)
3378                .map(|i| FrameSubmission {
3379                    page_number: page_nums[i],
3380                    page_data: pages[i].clone(),
3381                    db_size_if_commit: commit_sizes[i],
3382                })
3383                .collect(),
3384        );
3385        let batch2 = TransactionFrameBatch::new(
3386            (3..6)
3387                .map(|i| FrameSubmission {
3388                    page_number: page_nums[i],
3389                    page_data: pages[i].clone(),
3390                    db_size_if_commit: commit_sizes[i],
3391                })
3392                .collect(),
3393        );
3394
3395        write_consolidated_frames(&cx, &mut wal_g, &[batch1, batch2]).expect("group write");
3396        let group_checksum = wal_g.running_checksum();
3397        let group_count = wal_g.frame_count();
3398
3399        assert_eq!(single_count, group_count, "frame counts must match");
3400        assert_eq!(
3401            single_checksum, group_checksum,
3402            "group commit must produce identical checksum chain as single-frame append"
3403        );
3404
3405        // Verify byte-level frame content equality.
3406        for i in 0..6 {
3407            let (h_s, d_s) = wal_s.read_frame(&cx, i).expect("read single");
3408            let (h_g, d_g) = wal_g.read_frame(&cx, i).expect("read group");
3409            assert_eq!(h_s.page_number, h_g.page_number, "frame {i} page_number");
3410            assert_eq!(h_s.db_size, h_g.db_size, "frame {i} db_size");
3411            assert_eq!(h_s.checksum, h_g.checksum, "frame {i} checksum");
3412            assert_eq!(h_s.salts, h_g.salts, "frame {i} salts");
3413            assert_eq!(d_s, d_g, "frame {i} data");
3414        }
3415
3416        wal_s.close(&cx).expect("close single");
3417        wal_g.close(&cx).expect("close group");
3418    }
3419
3420    #[test]
3421    fn test_batch_append_checksum_chain_matches_single_append() {
3422        let cx = test_cx();
3423        let vfs_single = MemoryVfs::new();
3424        let vfs_batch = MemoryVfs::new();
3425
3426        let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3427        let page_nums: Vec<u32> = (1..=6u32).collect();
3428        let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3429
3430        let file_single = open_wal_file(&vfs_single, &cx);
3431        let mut wal_single =
3432            WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3433        for i in 0..6 {
3434            wal_single
3435                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3436                .expect("append single");
3437        }
3438
3439        let file_batch = open_wal_file(&vfs_batch, &cx);
3440        let mut wal_batch =
3441            WalFile::create(&cx, file_batch, PAGE_SIZE, 0, test_salts()).expect("create batch");
3442        let frames: Vec<_> = (0..6)
3443            .map(|i| WalAppendFrameRef {
3444                page_number: page_nums[i],
3445                page_data: &pages[i],
3446                db_size_if_commit: commit_sizes[i],
3447            })
3448            .collect();
3449        wal_batch.append_frames(&cx, &frames).expect("append batch");
3450
3451        assert_eq!(
3452            wal_single.frame_count(),
3453            wal_batch.frame_count(),
3454            "batch append must preserve frame count"
3455        );
3456        assert_eq!(
3457            wal_single.running_checksum(),
3458            wal_batch.running_checksum(),
3459            "batch append must preserve checksum chain"
3460        );
3461
3462        for i in 0..6 {
3463            let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3464            let (batch_header, batch_data) = wal_batch.read_frame(&cx, i).expect("read batch");
3465            assert_eq!(single_header, batch_header, "frame header {i} must match");
3466            assert_eq!(single_data, batch_data, "frame payload {i} must match");
3467        }
3468    }
3469
3470    #[test]
3471    fn tracked_batch_write_completion_is_reported_by_memory_source() {
3472        let cx = test_cx();
3473        let vfs = MemoryVfs::new();
3474        let file = open_wal_file(&vfs, &cx);
3475        let mut wal =
3476            WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create tracked WAL");
3477        let page = sample_page(0xA5);
3478        let frames = [WalAppendFrameRef {
3479            page_number: 1,
3480            page_data: &page,
3481            db_size_if_commit: 1,
3482        }];
3483        let completion = VfsWriteCompletion::new();
3484
3485        wal.append_frames_tracked(&cx, &frames, completion.clone())
3486            .expect("append tracked frame batch");
3487
3488        assert_eq!(
3489            completion.state(),
3490            fsqlite_vfs::VfsWriteCompletionState::Success
3491        );
3492        assert_eq!(wal.frame_count(), 1);
3493    }
3494
3495    /// bd-db300.3.8.6: Verify the fused append_frames path produces a
3496    /// byte-identical WAL file compared to the single-frame append path,
3497    /// including WAL header bytes and all frame header/payload bytes.
3498    #[test]
3499    fn test_fused_append_frames_produces_byte_identical_wal_file() {
3500        let cx = test_cx();
3501        let vfs_single = MemoryVfs::new();
3502        let vfs_fused = MemoryVfs::new();
3503
3504        let pages: Vec<Vec<u8>> = (0..4u8).map(sample_page).collect();
3505        let page_nums: Vec<u32> = vec![3, 1, 4, 2];
3506        let commit_sizes: Vec<u32> = vec![0, 0, 0, 4];
3507
3508        // Single-frame path (reference).
3509        let file_s = open_wal_file(&vfs_single, &cx);
3510        let mut wal_s =
3511            WalFile::create(&cx, file_s, PAGE_SIZE, 0, test_salts()).expect("create single");
3512        for i in 0..4 {
3513            wal_s
3514                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3515                .expect("append single");
3516        }
3517
3518        // Fused batch path (under test).
3519        let file_f = open_wal_file(&vfs_fused, &cx);
3520        let mut wal_f =
3521            WalFile::create(&cx, file_f, PAGE_SIZE, 0, test_salts()).expect("create fused");
3522        let frames: Vec<_> = (0..4)
3523            .map(|i| WalAppendFrameRef {
3524                page_number: page_nums[i],
3525                page_data: &pages[i],
3526                db_size_if_commit: commit_sizes[i],
3527            })
3528            .collect();
3529        wal_f.append_frames(&cx, &frames).expect("append fused");
3530
3531        // Compare checksums, frame count, and raw frame bytes.
3532        assert_eq!(wal_s.frame_count(), wal_f.frame_count());
3533        assert_eq!(wal_s.running_checksum(), wal_f.running_checksum());
3534
3535        let frame_size = wal_s.frame_size();
3536        for i in 0..4 {
3537            let mut buf_s = vec![0u8; frame_size];
3538            let mut buf_f = vec![0u8; frame_size];
3539            wal_s
3540                .read_frame_into(&cx, i, &mut buf_s)
3541                .expect("read single");
3542            wal_f
3543                .read_frame_into(&cx, i, &mut buf_f)
3544                .expect("read fused");
3545            assert_eq!(
3546                buf_s, buf_f,
3547                "raw frame bytes at index {i} must be identical"
3548            );
3549        }
3550    }
3551
3552    /// bd-db300.3.8.6: Verify that frame_scratch is restored after an error
3553    /// in append_frames (e.g. page size mismatch), so subsequent valid
3554    /// appends still work correctly.
3555    #[test]
3556    fn test_append_frames_restores_scratch_on_error() {
3557        let cx = test_cx();
3558        let vfs = MemoryVfs::new();
3559        let file = open_wal_file(&vfs, &cx);
3560        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3561
3562        // First, a valid append to establish scratch state.
3563        let good_page = sample_page(0x11);
3564        wal.append_frame(&cx, 1, &good_page, 0)
3565            .expect("first append");
3566        let checksum_before = wal.running_checksum();
3567        let scratch_cap_before = wal.frame_scratch_capacity();
3568        let mut first_frame_before = vec![0u8; wal.frame_size()];
3569        wal.read_frame_into(&cx, 0, &mut first_frame_before)
3570            .expect("read baseline frame");
3571
3572        // Attempt a batch with a bad page size — should fail.
3573        let bad_page = vec![0xBBu8; PAGE_SIZE as usize + 1]; // wrong size
3574        let good_page2 = sample_page(0x22);
3575        let bad_frames = vec![
3576            WalAppendFrameRef {
3577                page_number: 2,
3578                page_data: &good_page2,
3579                db_size_if_commit: 0,
3580            },
3581            WalAppendFrameRef {
3582                page_number: 3,
3583                page_data: &bad_page, // size mismatch
3584                db_size_if_commit: 3,
3585            },
3586        ];
3587        let err = wal.append_frames(&cx, &bad_frames);
3588        assert!(err.is_err(), "bad page size should cause error");
3589
3590        // Scratch must still be usable — frame_count unchanged.
3591        assert_eq!(
3592            wal.frame_count(),
3593            1,
3594            "failed append must not advance frame count"
3595        );
3596        assert_eq!(
3597            wal.running_checksum(),
3598            checksum_before,
3599            "failed append must preserve the running checksum of prior committed frames"
3600        );
3601        assert!(
3602            wal.frame_scratch_capacity() >= scratch_cap_before,
3603            "scratch capacity must not shrink after error"
3604        );
3605        let mut first_frame_after = vec![0u8; wal.frame_size()];
3606        wal.read_frame_into(&cx, 0, &mut first_frame_after)
3607            .expect("read preserved frame");
3608        assert_eq!(
3609            first_frame_after, first_frame_before,
3610            "failed append must not rewrite previously committed raw frame bytes"
3611        );
3612
3613        // Recovery: subsequent valid append must succeed and produce correct checksums.
3614        let recovery_page = sample_page(0x33);
3615        wal.append_frame(&cx, 2, &recovery_page, 2)
3616            .expect("recovery append after error");
3617        assert_eq!(wal.frame_count(), 2, "recovery append should succeed");
3618    }
3619
3620    #[test]
3621    fn test_append_frame_reuses_frame_scratch_between_calls() {
3622        let cx = test_cx();
3623        let vfs = MemoryVfs::new();
3624        let file = open_wal_file(&vfs, &cx);
3625        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3626
3627        wal.append_frame(&cx, 1, &sample_page(0x11), 0)
3628            .expect("append first");
3629        let scratch_len = wal.frame_scratch_len();
3630        let scratch_capacity = wal.frame_scratch_capacity();
3631        let scratch_ptr = wal.frame_scratch_ptr();
3632
3633        wal.append_frame(&cx, 2, &sample_page(0x22), 2)
3634            .expect("append second");
3635
3636        assert_eq!(
3637            wal.frame_scratch_len(),
3638            wal.frame_size(),
3639            "single-frame append should keep one frame sized scratch"
3640        );
3641        assert_eq!(
3642            wal.frame_scratch_len(),
3643            scratch_len,
3644            "single-frame scratch length should stay constant across appends"
3645        );
3646        assert_eq!(
3647            wal.frame_scratch_capacity(),
3648            scratch_capacity,
3649            "single-frame scratch should retain its allocation"
3650        );
3651        assert_eq!(
3652            wal.frame_scratch_ptr(),
3653            scratch_ptr,
3654            "single-frame scratch should reuse the same backing buffer"
3655        );
3656    }
3657
3658    #[test]
3659    fn test_batch_append_reuses_frame_scratch_between_calls() {
3660        let cx = test_cx();
3661        let vfs = MemoryVfs::new();
3662        let file = open_wal_file(&vfs, &cx);
3663        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3664
3665        let first_pages: Vec<Vec<u8>> = (0..3u8).map(sample_page).collect();
3666        let first_frames: Vec<_> = (0..3)
3667            .map(|i| WalAppendFrameRef {
3668                page_number: u32::try_from(i).expect("index fits u32") + 1,
3669                page_data: &first_pages[i],
3670                db_size_if_commit: 0,
3671            })
3672            .collect();
3673        wal.append_frames(&cx, &first_frames)
3674            .expect("append first batch");
3675        let scratch_capacity = wal.frame_scratch_capacity();
3676        let scratch_ptr = wal.frame_scratch_ptr();
3677
3678        let second_pages: Vec<Vec<u8>> = (0..2u8).map(|i| sample_page(i + 10)).collect();
3679        let second_frames: Vec<_> = (0..2)
3680            .map(|i| WalAppendFrameRef {
3681                page_number: u32::try_from(i).expect("index fits u32") + 10,
3682                page_data: &second_pages[i],
3683                db_size_if_commit: if i == 1 { 11 } else { 0 },
3684            })
3685            .collect();
3686        wal.append_frames(&cx, &second_frames)
3687            .expect("append second batch");
3688
3689        assert_eq!(
3690            wal.frame_scratch_len(),
3691            second_frames.len() * wal.frame_size(),
3692            "batch scratch length should track the active batch size"
3693        );
3694        assert_eq!(
3695            wal.frame_scratch_capacity(),
3696            scratch_capacity,
3697            "smaller follow-on batches should retain the existing scratch allocation"
3698        );
3699        assert_eq!(
3700            wal.frame_scratch_ptr(),
3701            scratch_ptr,
3702            "smaller follow-on batches should reuse the same backing buffer"
3703        );
3704    }
3705
3706    #[test]
3707    fn test_reset_clears_frame_scratch_len_without_dropping_capacity() {
3708        let cx = test_cx();
3709        let vfs = MemoryVfs::new();
3710        let file = open_wal_file(&vfs, &cx);
3711        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
3712        let reset_salts = WalSalts {
3713            salt1: 0xABCD_EF01,
3714            salt2: 0x1020_3040,
3715        };
3716
3717        let pages: Vec<Vec<u8>> = (0..4u8).map(sample_page).collect();
3718        let frames: Vec<_> = (0..4)
3719            .map(|i| WalAppendFrameRef {
3720                page_number: u32::try_from(i).expect("index fits u32") + 1,
3721                page_data: &pages[i],
3722                db_size_if_commit: if i == 3 { 4 } else { 0 },
3723            })
3724            .collect();
3725        wal.append_frames(&cx, &frames).expect("append batch");
3726        let scratch_capacity = wal.frame_scratch_capacity();
3727        let scratch_ptr = wal.frame_scratch_ptr();
3728
3729        wal.reset(&cx, 1, reset_salts, false).expect("reset WAL");
3730
3731        assert_eq!(
3732            wal.frame_scratch_len(),
3733            0,
3734            "reset should leave scratch empty for the next append cycle"
3735        );
3736        assert_eq!(
3737            wal.frame_scratch_capacity(),
3738            scratch_capacity,
3739            "reset should preserve scratch capacity for reuse"
3740        );
3741        assert_eq!(
3742            wal.frame_scratch_ptr(),
3743            scratch_ptr,
3744            "reset should keep the existing scratch allocation alive"
3745        );
3746    }
3747
3748    #[test]
3749    #[ignore = "benchmark evidence only"]
3750    fn wal_frame_scratch_benchmark_report() {
3751        let cases = vec![
3752            track_c_scratch_case_report(
3753                "single_frame_256_ops",
3754                1,
3755                256,
3756                || {
3757                    track_c_measure_single_frame_case(
3758                        TrackCScratchBenchMode::FreshAllocBaseline,
3759                        256,
3760                    )
3761                },
3762                || {
3763                    track_c_measure_single_frame_case(
3764                        TrackCScratchBenchMode::ScratchReuseCandidate,
3765                        256,
3766                    )
3767                },
3768            ),
3769            track_c_scratch_case_report(
3770                "batch_8_frames_64_ops",
3771                8,
3772                64,
3773                || track_c_measure_batch_case::<8>(TrackCScratchBenchMode::FreshAllocBaseline, 64),
3774                || {
3775                    track_c_measure_batch_case::<8>(
3776                        TrackCScratchBenchMode::ScratchReuseCandidate,
3777                        64,
3778                    )
3779                },
3780            ),
3781            track_c_scratch_case_report(
3782                "batch_32_frames_16_ops",
3783                32,
3784                16,
3785                || track_c_measure_batch_case::<32>(TrackCScratchBenchMode::FreshAllocBaseline, 16),
3786                || {
3787                    track_c_measure_batch_case::<32>(
3788                        TrackCScratchBenchMode::ScratchReuseCandidate,
3789                        16,
3790                    )
3791                },
3792            ),
3793        ];
3794
3795        let report = json!({
3796            "schema_version": "fsqlite.track_c.wal_scratch_benchmark.v1",
3797            "bead_id": TRACK_C_SCRATCH_BENCH_BEAD_ID,
3798            "parent_bead_id": "bd-db300.3.4",
3799            "measured_operation": "wal_frame_assembly_and_append",
3800            "warmup_iterations": TRACK_C_SCRATCH_BENCH_WARMUP_ITERS,
3801            "measurement_iterations": TRACK_C_SCRATCH_BENCH_MEASURE_ITERS,
3802            "vfs": "memory",
3803            "baseline_variant": "fresh_frame_buffer_per_operation",
3804            "candidate_variant": "reusable_wal_handle_frame_scratch",
3805            "cases": cases,
3806        });
3807
3808        println!("BEGIN_BD_DB300_3_4_3_REPORT");
3809        println!("{}", serde_json::to_string_pretty(&report).unwrap());
3810        println!("END_BD_DB300_3_4_3_REPORT");
3811    }
3812
3813    #[test]
3814    fn test_prepared_batch_append_checksum_chain_matches_single_append() {
3815        let cx = test_cx();
3816        let vfs_single = MemoryVfs::new();
3817        let vfs_prepared = MemoryVfs::new();
3818        let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3819
3820        let pages: Vec<Vec<u8>> = (0..6u8).map(sample_page).collect();
3821        let page_nums: Vec<u32> = (1..=6u32).collect();
3822        let commit_sizes: Vec<u32> = vec![0, 0, 3, 0, 0, 6];
3823
3824        let file_single = open_wal_file(&vfs_single, &cx);
3825        let mut wal_single =
3826            WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3827        for i in 0..6 {
3828            wal_single
3829                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3830                .expect("append single");
3831        }
3832
3833        let file_prepared = open_wal_file(&vfs_prepared, &cx);
3834        let mut wal_prepared = WalFile::create(&cx, file_prepared, PAGE_SIZE, 0, test_salts())
3835            .expect("create prepared");
3836        let frames: Vec<_> = (0..6)
3837            .map(|i| WalAppendFrameRef {
3838                page_number: page_nums[i],
3839                page_data: &pages[i],
3840                db_size_if_commit: commit_sizes[i],
3841            })
3842            .collect();
3843        let mut prepared_bytes = wal_prepared
3844            .prepare_frame_bytes(&frames)
3845            .expect("prepare frame bytes");
3846        let frame_transforms = prepared_bytes
3847            .chunks_exact(wal_prepared.frame_size())
3848            .map(|frame| {
3849                WalChecksumTransform::for_wal_frame(
3850                    frame,
3851                    page_size,
3852                    wal_prepared.big_endian_checksum(),
3853                )
3854            })
3855            .collect::<Result<Vec<_>>>()
3856            .expect("compute frame transforms");
3857        wal_prepared
3858            .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
3859            .expect("append prepared batch");
3860
3861        assert_eq!(
3862            wal_single.frame_count(),
3863            wal_prepared.frame_count(),
3864            "prepared append must preserve frame count"
3865        );
3866        assert_eq!(
3867            wal_single.running_checksum(),
3868            wal_prepared.running_checksum(),
3869            "prepared append must preserve checksum chain"
3870        );
3871
3872        for i in 0..6 {
3873            let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3874            let (prepared_header, prepared_data) =
3875                wal_prepared.read_frame(&cx, i).expect("read prepared");
3876            assert_eq!(
3877                single_header, prepared_header,
3878                "frame header {i} must match"
3879            );
3880            assert_eq!(single_data, prepared_data, "frame payload {i} must match");
3881        }
3882    }
3883
3884    #[test]
3885    fn test_prepared_batch_reseeds_after_intervening_growth() {
3886        let cx = test_cx();
3887        let vfs_single = MemoryVfs::new();
3888        let vfs_prepared = MemoryVfs::new();
3889        let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3890
3891        let pages: Vec<Vec<u8>> = (0..3u8).map(sample_page).collect();
3892        let page_nums: Vec<u32> = (1..=3u32).collect();
3893        let commit_sizes: Vec<u32> = vec![0, 0, 3];
3894        let intervening_page = sample_page(0xAA);
3895
3896        let file_single = open_wal_file(&vfs_single, &cx);
3897        let mut wal_single =
3898            WalFile::create(&cx, file_single, PAGE_SIZE, 0, test_salts()).expect("create single");
3899        wal_single
3900            .append_frame(&cx, 99, &intervening_page, 0)
3901            .expect("append intervening single");
3902        for i in 0..3 {
3903            wal_single
3904                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3905                .expect("append single");
3906        }
3907
3908        let file_prepared = open_wal_file(&vfs_prepared, &cx);
3909        let mut wal_prepared = WalFile::create(&cx, file_prepared, PAGE_SIZE, 0, test_salts())
3910            .expect("create prepared");
3911        let frames: Vec<_> = (0..3)
3912            .map(|i| WalAppendFrameRef {
3913                page_number: page_nums[i],
3914                page_data: &pages[i],
3915                db_size_if_commit: commit_sizes[i],
3916            })
3917            .collect();
3918        let mut prepared_bytes = wal_prepared
3919            .prepare_frame_bytes(&frames)
3920            .expect("prepare frame bytes");
3921        let frame_transforms = prepared_bytes
3922            .chunks_exact(wal_prepared.frame_size())
3923            .map(|frame| {
3924                WalChecksumTransform::for_wal_frame(
3925                    frame,
3926                    page_size,
3927                    wal_prepared.big_endian_checksum(),
3928                )
3929            })
3930            .collect::<Result<Vec<_>>>()
3931            .expect("compute frame transforms");
3932        wal_prepared
3933            .append_frame(&cx, 99, &intervening_page, 0)
3934            .expect("append intervening prepared");
3935        wal_prepared
3936            .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
3937            .expect("append prepared batch");
3938
3939        assert_eq!(
3940            wal_single.frame_count(),
3941            wal_prepared.frame_count(),
3942            "prepared append after growth must preserve frame count"
3943        );
3944        assert_eq!(
3945            wal_single.running_checksum(),
3946            wal_prepared.running_checksum(),
3947            "prepared append after growth must rebind to the live checksum seed"
3948        );
3949
3950        for i in 0..wal_single.frame_count() {
3951            let (single_header, single_data) = wal_single.read_frame(&cx, i).expect("read single");
3952            let (prepared_header, prepared_data) =
3953                wal_prepared.read_frame(&cx, i).expect("read prepared");
3954            assert_eq!(
3955                single_header, prepared_header,
3956                "frame header {i} must match"
3957            );
3958            assert_eq!(single_data, prepared_data, "frame payload {i} must match");
3959        }
3960    }
3961
3962    #[test]
3963    fn test_prepared_batch_rewrites_salts_after_reset() {
3964        let cx = test_cx();
3965        let vfs_fresh = MemoryVfs::new();
3966        let vfs_reset = MemoryVfs::new();
3967        let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
3968        let reset_salts = WalSalts {
3969            salt1: 0x0102_0304,
3970            salt2: 0xA0B0_C0D0,
3971        };
3972
3973        let pages: Vec<Vec<u8>> = (0..2u8).map(sample_page).collect();
3974        let page_nums: Vec<u32> = (1..=2u32).collect();
3975        let commit_sizes: Vec<u32> = vec![0, 2];
3976
3977        let file_fresh = open_wal_file(&vfs_fresh, &cx);
3978        let mut wal_fresh =
3979            WalFile::create(&cx, file_fresh, PAGE_SIZE, 7, reset_salts).expect("create fresh");
3980        for i in 0..2 {
3981            wal_fresh
3982                .append_frame(&cx, page_nums[i], &pages[i], commit_sizes[i])
3983                .expect("append fresh");
3984        }
3985
3986        let file_reset = open_wal_file(&vfs_reset, &cx);
3987        let mut wal_reset =
3988            WalFile::create(&cx, file_reset, PAGE_SIZE, 0, test_salts()).expect("create reset");
3989        let frames: Vec<_> = (0..2)
3990            .map(|i| WalAppendFrameRef {
3991                page_number: page_nums[i],
3992                page_data: &pages[i],
3993                db_size_if_commit: commit_sizes[i],
3994            })
3995            .collect();
3996        let mut prepared_bytes = wal_reset
3997            .prepare_frame_bytes(&frames)
3998            .expect("prepare frame bytes");
3999        let frame_transforms = prepared_bytes
4000            .chunks_exact(wal_reset.frame_size())
4001            .map(|frame| {
4002                WalChecksumTransform::for_wal_frame(
4003                    frame,
4004                    page_size,
4005                    wal_reset.big_endian_checksum(),
4006                )
4007            })
4008            .collect::<Result<Vec<_>>>()
4009            .expect("compute frame transforms");
4010        wal_reset
4011            .reset(&cx, 7, reset_salts, false)
4012            .expect("reset WAL");
4013        wal_reset
4014            .append_prepared_frame_bytes(&cx, &mut prepared_bytes, &frame_transforms)
4015            .expect("append prepared batch");
4016
4017        assert_eq!(
4018            wal_fresh.frame_count(),
4019            wal_reset.frame_count(),
4020            "prepared append after reset must preserve frame count"
4021        );
4022        assert_eq!(
4023            wal_fresh.running_checksum(),
4024            wal_reset.running_checksum(),
4025            "prepared append after reset must rebind to the reset checksum seed"
4026        );
4027
4028        for i in 0..2 {
4029            let (fresh_header, fresh_data) = wal_fresh.read_frame(&cx, i).expect("read fresh");
4030            let (reset_header, reset_data) = wal_reset.read_frame(&cx, i).expect("read reset");
4031            assert_eq!(fresh_header, reset_header, "frame header {i} must match");
4032            assert_eq!(
4033                fresh_header.salts, reset_salts,
4034                "frame {i} must use reset salts"
4035            );
4036            assert_eq!(fresh_data, reset_data, "frame payload {i} must match");
4037        }
4038    }
4039
4040    #[test]
4041    fn test_uncommitted_tail_trimmed_on_reopen() {
4042        // Write 5 frames: commit at frame 3, no commit after.
4043        // On reopen, only frames up to the last commit (3) should survive.
4044        let cx = test_cx();
4045        let vfs = MemoryVfs::new();
4046        let file = open_wal_file(&vfs, &cx);
4047        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4048
4049        let commit_map: [(u32, u32); 5] = [(1, 0), (2, 0), (3, 3), (4, 0), (5, 0)];
4050        for (pg, db_sz) in commit_map {
4051            wal.append_frame(&cx, pg, &sample_page(u8::try_from(pg).unwrap()), db_sz)
4052                .expect("append");
4053        }
4054        assert_eq!(wal.frame_count(), 5);
4055        wal.close(&cx).expect("close");
4056
4057        // Reopen: uncommitted tail (frames 4,5) should be trimmed.
4058        let file2 = open_wal_file(&vfs, &cx);
4059        let wal2 = WalFile::open(&cx, file2).expect("reopen");
4060        assert_eq!(
4061            wal2.frame_count(),
4062            3,
4063            "frames after last commit should be trimmed on reopen"
4064        );
4065        wal2.close(&cx).expect("close");
4066    }
4067
4068    #[test]
4069    fn test_large_transaction_50_frames() {
4070        // A single transaction writing 50 frames (commit only on last).
4071        let cx = test_cx();
4072        let vfs = MemoryVfs::new();
4073        let file = open_wal_file(&vfs, &cx);
4074        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4075
4076        let n = 50u32;
4077        for i in 0..n {
4078            let db_size = if i == n - 1 { n } else { 0 };
4079            let seed = u8::try_from(i % 251).unwrap();
4080            wal.append_frame(&cx, i + 1, &sample_page(seed), db_size)
4081                .expect("append");
4082        }
4083        assert_eq!(wal.frame_count(), usize::try_from(n).unwrap());
4084        let final_checksum = wal.running_checksum();
4085        wal.close(&cx).expect("close");
4086
4087        // Reopen and verify all 50 frames survived (single commit at end).
4088        let file2 = open_wal_file(&vfs, &cx);
4089        let wal2 = WalFile::open(&cx, file2).expect("reopen");
4090        assert_eq!(wal2.frame_count(), usize::try_from(n).unwrap());
4091        assert_eq!(wal2.running_checksum(), final_checksum);
4092
4093        // Spot-check first, middle, last frames.
4094        for idx in [0, 24, 49] {
4095            let (h, d) = wal2.read_frame(&cx, idx).expect("read");
4096            let i = u32::try_from(idx).unwrap();
4097            assert_eq!(h.page_number, i + 1);
4098            assert_eq!(d, sample_page(u8::try_from(i % 251).unwrap()));
4099        }
4100
4101        wal2.close(&cx).expect("close");
4102    }
4103
4104    #[test]
4105    fn test_append_after_reset_checksum_independent() {
4106        // After reset, the checksum chain starts fresh from the new header.
4107        // Identical frames appended to a fresh WAL and a reset WAL with the
4108        // same salts should yield the same checksums.
4109        let cx = test_cx();
4110
4111        let salts = WalSalts {
4112            salt1: 0x1234_5678,
4113            salt2: 0x9ABC_DEF0,
4114        };
4115
4116        // Fresh WAL.
4117        let vfs1 = MemoryVfs::new();
4118        let file1 = open_wal_file(&vfs1, &cx);
4119        let mut wal_fresh = WalFile::create(&cx, file1, PAGE_SIZE, 1, salts).expect("create fresh");
4120        wal_fresh
4121            .append_frame(&cx, 1, &sample_page(0x42), 1)
4122            .expect("append fresh");
4123        let fresh_checksum = wal_fresh.running_checksum();
4124        wal_fresh.close(&cx).expect("close fresh");
4125
4126        // WAL that was written to, then reset to same salts and checkpoint_seq.
4127        let vfs2 = MemoryVfs::new();
4128        let file2 = open_wal_file(&vfs2, &cx);
4129        let mut wal_reset =
4130            WalFile::create(&cx, file2, PAGE_SIZE, 0, test_salts()).expect("create reset");
4131        // Write some frames.
4132        wal_reset
4133            .append_frame(&cx, 99, &sample_page(0xFF), 99)
4134            .expect("append old");
4135        // Reset to same salts as fresh.
4136        wal_reset.reset(&cx, 1, salts, false).expect("reset");
4137        wal_reset
4138            .append_frame(&cx, 1, &sample_page(0x42), 1)
4139            .expect("append after reset");
4140        let reset_checksum = wal_reset.running_checksum();
4141        wal_reset.close(&cx).expect("close reset");
4142
4143        assert_eq!(
4144            fresh_checksum, reset_checksum,
4145            "after reset with same salts, checksum chain must match fresh WAL"
4146        );
4147    }
4148
4149    // ── bd-xfn30.3: Fault-injection e2e crash matrix ──
4150    //
4151    // Deterministic crash-at-every-boundary scenarios with recovery validation.
4152
4153    /// Build a WAL with two committed transactions and return the VFS.
4154    /// Txn1: frames 1-3 (commit at 3, db_size=3)
4155    /// Txn2: frames 4-6 (commit at 6, db_size=6)
4156    fn build_two_txn_wal() -> (MemoryVfs, Vec<Vec<u8>>) {
4157        let cx = test_cx();
4158        let vfs = MemoryVfs::new();
4159        let file = open_wal_file(&vfs, &cx);
4160        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4161
4162        let mut pages = Vec::new();
4163        let frame_specs: [(u32, u32); 6] = [
4164            (1, 0),
4165            (2, 0),
4166            (3, 3), // txn1
4167            (4, 0),
4168            (5, 0),
4169            (6, 6), // txn2
4170        ];
4171        for (pg, db_sz) in frame_specs {
4172            let page = sample_page(u8::try_from(pg).unwrap());
4173            wal.append_frame(&cx, pg, &page, db_sz).expect("append");
4174            pages.push(page);
4175        }
4176        wal.close(&cx).expect("close");
4177        (vfs, pages)
4178    }
4179
4180    #[test]
4181    fn test_crash_matrix_truncate_at_every_frame_boundary() {
4182        // For a WAL with 6 frames (2 txns), truncate at every possible
4183        // frame boundary and verify recovery gives the right frame count.
4184        let cx = test_cx();
4185        let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4186
4187        for cut_frames in 0..=6usize {
4188            // Rebuild fresh WAL for each truncation point.
4189            let (vfs, _) = build_two_txn_wal();
4190
4191            let cut_at = WAL_HEADER_SIZE + cut_frames * frame_size;
4192            let mut f = open_wal_file(&vfs, &cx);
4193            f.truncate(&cx, u64::try_from(cut_at).unwrap())
4194                .expect("truncate");
4195            drop(f);
4196
4197            let f2 = open_wal_file(&vfs, &cx);
4198            let wal = WalFile::open(&cx, f2).expect("open after truncation");
4199            let expected = match cut_frames {
4200                0..=2 => 0, // no commit yet
4201                3..=5 => 3, // first txn committed
4202                6 => 6,     // both txns committed
4203                _ => unreachable!(),
4204            };
4205            assert_eq!(
4206                wal.frame_count(),
4207                expected,
4208                "truncated at {cut_frames} frames should give {expected} committed"
4209            );
4210            wal.close(&cx).expect("close");
4211        }
4212
4213        // Also test partial-frame truncation at various byte offsets.
4214        for partial in 0..20usize {
4215            let (vfs, _) = build_two_txn_wal();
4216            let cx = test_cx();
4217
4218            let cut_byte = WAL_HEADER_SIZE + partial * frame_size / 3;
4219            let mut f = open_wal_file(&vfs, &cx);
4220            f.truncate(&cx, u64::try_from(cut_byte).unwrap())
4221                .expect("truncate");
4222            drop(f);
4223
4224            let f2 = open_wal_file(&vfs, &cx);
4225            let wal = WalFile::open(&cx, f2).expect("open");
4226            // Recovery should give 0, 3, or 6 committed frames (never partial).
4227            assert!(
4228                wal.frame_count() == 0 || wal.frame_count() == 3 || wal.frame_count() == 6,
4229                "cut_byte={cut_byte} gave frame_count={}, expected 0/3/6",
4230                wal.frame_count()
4231            );
4232            wal.close(&cx).expect("close");
4233        }
4234    }
4235
4236    #[test]
4237    fn test_crash_matrix_bit_flip_at_every_frame() {
4238        // Flip a byte in each frame's data and verify recovery truncates
4239        // to the correct committed prefix.
4240        for target_frame in 0..6usize {
4241            let (vfs, _) = build_two_txn_wal();
4242            let cx = test_cx();
4243
4244            let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4245            let corrupt_offset =
4246                WAL_HEADER_SIZE + target_frame * frame_size + WAL_FRAME_HEADER_SIZE + 42;
4247
4248            // Corrupt one byte.
4249            let f = open_wal_file(&vfs, &cx);
4250            let mut buf = [0u8; 1];
4251            let off = u64::try_from(corrupt_offset).unwrap();
4252            f.read(&cx, &mut buf, off).expect("read");
4253            buf[0] ^= 0xFF;
4254            f.write(&cx, &buf, off).expect("write corrupt");
4255            drop(f);
4256
4257            let f2 = open_wal_file(&vfs, &cx);
4258            let wal = WalFile::open(&cx, f2).expect("open");
4259            let expected = if target_frame < 3 {
4260                0 // corruption in txn1 — no committed frames
4261            } else {
4262                3 // corruption in txn2 — txn1 survives
4263            };
4264            assert_eq!(
4265                wal.frame_count(),
4266                expected,
4267                "bit flip in frame {target_frame} should give {expected}"
4268            );
4269            wal.close(&cx).expect("close");
4270        }
4271    }
4272
4273    #[test]
4274    fn test_crash_matrix_continue_after_recovery() {
4275        // After recovery from a crash, verify that new frames can be appended
4276        // and the checksum chain continues correctly.
4277        let (vfs, _) = build_two_txn_wal();
4278        let cx = test_cx();
4279
4280        let frame_size = WAL_FRAME_HEADER_SIZE + usize::try_from(PAGE_SIZE).unwrap();
4281
4282        // Corrupt frame 5 (in txn2), so recovery yields 3 frames (txn1).
4283        let corrupt_offset = WAL_HEADER_SIZE + 4 * frame_size + WAL_FRAME_HEADER_SIZE + 10;
4284        let f = open_wal_file(&vfs, &cx);
4285        let mut buf = [0u8; 1];
4286        let off = u64::try_from(corrupt_offset).unwrap();
4287        f.read(&cx, &mut buf, off).expect("read");
4288        buf[0] ^= 0xAA;
4289        f.write(&cx, &buf, off).expect("write corrupt");
4290        drop(f);
4291
4292        // Recover.
4293        let f2 = open_wal_file(&vfs, &cx);
4294        let mut wal = WalFile::open(&cx, f2).expect("open");
4295        assert_eq!(wal.frame_count(), 3);
4296
4297        // Append new transaction (frames 4-5, commit at 5).
4298        wal.append_frame(&cx, 10, &sample_page(0xAA), 0)
4299            .expect("append");
4300        wal.append_frame(&cx, 11, &sample_page(0xBB), 5)
4301            .expect("append commit");
4302        assert_eq!(wal.frame_count(), 5);
4303        let checksum_after = wal.running_checksum();
4304        wal.close(&cx).expect("close");
4305
4306        // Verify the new transaction persists.
4307        let f3 = open_wal_file(&vfs, &cx);
4308        let wal2 = WalFile::open(&cx, f3).expect("reopen");
4309        assert_eq!(wal2.frame_count(), 5);
4310        assert_eq!(wal2.running_checksum(), checksum_after);
4311
4312        // Verify original txn1 data intact.
4313        for i in 0..3 {
4314            let (h, d) = wal2.read_frame(&cx, i).expect("read");
4315            let pg = u32::try_from(i + 1).unwrap();
4316            assert_eq!(h.page_number, pg);
4317            assert_eq!(d, sample_page(u8::try_from(pg).unwrap()));
4318        }
4319
4320        // Verify new data.
4321        let (h4, d4) = wal2.read_frame(&cx, 3).expect("read new frame 4");
4322        assert_eq!(h4.page_number, 10);
4323        assert_eq!(d4, sample_page(0xAA));
4324
4325        wal2.close(&cx).expect("close");
4326    }
4327
4328    #[test]
4329    fn test_crash_matrix_zero_length_wal() {
4330        // WAL file with only a header (no frames) simulates crash before any write.
4331        let cx = test_cx();
4332        let vfs = MemoryVfs::new();
4333        let file = open_wal_file(&vfs, &cx);
4334        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4335        wal.close(&cx).expect("close");
4336
4337        let f2 = open_wal_file(&vfs, &cx);
4338        let wal2 = WalFile::open(&cx, f2).expect("open");
4339        assert_eq!(wal2.frame_count(), 0);
4340        wal2.close(&cx).expect("close");
4341    }
4342
4343    #[test]
4344    fn test_crash_matrix_header_only_partial_first_frame() {
4345        // WAL header plus partial first frame.
4346        let cx = test_cx();
4347        let vfs = MemoryVfs::new();
4348        let file = open_wal_file(&vfs, &cx);
4349
4350        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4351        wal.append_frame(&cx, 1, &sample_page(1), 1)
4352            .expect("append");
4353        let partial_size = WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + 10;
4354        wal.file_mut()
4355            .truncate(&cx, u64::try_from(partial_size).unwrap())
4356            .expect("truncate");
4357        wal.close(&cx).expect("close");
4358
4359        let f2 = open_wal_file(&vfs, &cx);
4360        let wal2 = WalFile::open(&cx, f2).expect("open");
4361        assert_eq!(wal2.frame_count(), 0, "partial frame should be dropped");
4362        wal2.close(&cx).expect("close");
4363    }
4364
4365    #[test]
4366    fn test_crash_matrix_many_txns_deterministic_recovery() {
4367        // 10 transactions of 3 frames each (30 total frames).
4368        // Crash at each transaction boundary and verify recovery.
4369        let cx = test_cx();
4370
4371        for crash_txn in 0..=10usize {
4372            let vfs = MemoryVfs::new();
4373            let file = open_wal_file(&vfs, &cx);
4374            let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4375
4376            let total_frames = crash_txn * 3;
4377            for txn in 0..crash_txn {
4378                for f in 0..3u32 {
4379                    let pg = u32::try_from(txn * 3).unwrap() + f + 1;
4380                    let db_size = if f == 2 {
4381                        u32::try_from(txn * 3 + 3).unwrap()
4382                    } else {
4383                        0
4384                    };
4385                    let seed = u8::try_from(pg % 251).unwrap();
4386                    wal.append_frame(&cx, pg, &sample_page(seed), db_size)
4387                        .expect("append");
4388                }
4389            }
4390            assert_eq!(wal.frame_count(), total_frames);
4391            wal.close(&cx).expect("close");
4392
4393            // Reopen: all frames should survive (all txns committed).
4394            let f2 = open_wal_file(&vfs, &cx);
4395            let wal2 = WalFile::open(&cx, f2).expect("open");
4396            assert_eq!(
4397                wal2.frame_count(),
4398                total_frames,
4399                "crash_txn={crash_txn}: all {total_frames} committed frames should survive"
4400            );
4401            wal2.close(&cx).expect("close");
4402
4403            // Now truncate mid-way through the next (incomplete) txn.
4404            if crash_txn < 10 {
4405                // Write 1 more uncommitted frame.
4406                let f3 = open_wal_file(&vfs, &cx);
4407                let mut wal3 = WalFile::open(&cx, f3).expect("open");
4408                let extra_pg = u32::try_from(total_frames + 1).unwrap();
4409                wal3.append_frame(
4410                    &cx,
4411                    extra_pg,
4412                    &sample_page(u8::try_from(extra_pg % 251).unwrap()),
4413                    0,
4414                )
4415                .expect("append uncommitted");
4416                wal3.close(&cx).expect("close");
4417
4418                // Reopen: uncommitted frame should be dropped.
4419                let f4 = open_wal_file(&vfs, &cx);
4420                let wal4 = WalFile::open(&cx, f4).expect("open");
4421                assert_eq!(
4422                    wal4.frame_count(),
4423                    total_frames,
4424                    "crash_txn={crash_txn}: uncommitted extra frame dropped"
4425                );
4426                wal4.close(&cx).expect("close");
4427            }
4428        }
4429    }
4430
4431    #[test]
4432    fn test_crash_matrix_reset_then_crash() {
4433        // Reset WAL, write partial txn, crash. Recovery should give 0 frames.
4434        let cx = test_cx();
4435        let vfs = MemoryVfs::new();
4436        let file = open_wal_file(&vfs, &cx);
4437        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4438
4439        // Write and commit.
4440        wal.append_frame(&cx, 1, &sample_page(1), 1)
4441            .expect("append");
4442        // Reset.
4443        let new_salts = WalSalts {
4444            salt1: 0x5555_6666,
4445            salt2: 0x7777_8888,
4446        };
4447        wal.reset(&cx, 1, new_salts, true).expect("reset");
4448        assert_eq!(wal.frame_count(), 0);
4449
4450        // Write partial txn (no commit).
4451        wal.append_frame(&cx, 1, &sample_page(0xCC), 0)
4452            .expect("append");
4453        wal.append_frame(&cx, 2, &sample_page(0xDD), 0)
4454            .expect("append");
4455        wal.close(&cx).expect("close");
4456
4457        // Reopen: no committed frames after reset.
4458        let f2 = open_wal_file(&vfs, &cx);
4459        let wal2 = WalFile::open(&cx, f2).expect("open");
4460        assert_eq!(wal2.frame_count(), 0, "no commits after reset");
4461        assert_eq!(wal2.header().salts, new_salts);
4462        wal2.close(&cx).expect("close");
4463    }
4464
4465    #[test]
4466    fn test_truncated_file_mid_second_txn_recovers_first_commit() {
4467        let cx = test_cx();
4468        let vfs = MemoryVfs::new();
4469        let file = open_wal_file(&vfs, &cx);
4470
4471        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4472
4473        // Txn 1: commit at frame 2.
4474        wal.append_frame(&cx, 1, &sample_page(0x11), 0)
4475            .expect("append");
4476        wal.append_frame(&cx, 2, &sample_page(0x22), 2)
4477            .expect("commit txn1");
4478
4479        // Txn 2: partial — one full frame + start of another.
4480        wal.append_frame(&cx, 3, &sample_page(0x33), 0)
4481            .expect("append");
4482        wal.append_frame(&cx, 4, &sample_page(0x44), 0)
4483            .expect("append");
4484        assert_eq!(wal.frame_count(), 4);
4485        let frame_size = wal.frame_size();
4486        wal.close(&cx).expect("close");
4487
4488        // Truncate: remove the last frame entirely and half of frame 3.
4489        let truncate_offset =
4490            u64::try_from(WAL_HEADER_SIZE + frame_size * 2 + frame_size / 2).unwrap();
4491        let mut f = open_wal_file(&vfs, &cx);
4492        f.truncate(&cx, truncate_offset).expect("truncate");
4493        drop(f);
4494
4495        let file2 = open_wal_file(&vfs, &cx);
4496        let wal2 = WalFile::open(&cx, file2).expect("reopen after truncation");
4497        assert_eq!(
4498            wal2.frame_count(),
4499            2,
4500            "only first committed transaction (2 frames) should survive truncation"
4501        );
4502        let h = wal2.read_frame_header(&cx, 1).expect("read frame 2 header");
4503        assert!(h.is_commit(), "frame 2 must be a commit frame");
4504        wal2.close(&cx).expect("close");
4505    }
4506
4507    #[test]
4508    fn test_recovery_is_idempotent_across_multiple_reopens() {
4509        let cx = test_cx();
4510        let vfs = MemoryVfs::new();
4511        let file = open_wal_file(&vfs, &cx);
4512
4513        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4514
4515        // Two committed transactions.
4516        wal.append_frame(&cx, 1, &sample_page(0xAA), 0)
4517            .expect("append");
4518        wal.append_frame(&cx, 2, &sample_page(0xBB), 2)
4519            .expect("commit txn1");
4520        wal.append_frame(&cx, 3, &sample_page(0xCC), 0)
4521            .expect("append");
4522        wal.append_frame(&cx, 4, &sample_page(0xDD), 4)
4523            .expect("commit txn2");
4524        // One uncommitted frame.
4525        wal.append_frame(&cx, 5, &sample_page(0xEE), 0)
4526            .expect("append");
4527        wal.close(&cx).expect("close");
4528
4529        // Reopen three times — frame count must be stable.
4530        for reopen in 0..3_u32 {
4531            let f = open_wal_file(&vfs, &cx);
4532            let wal_reopened = WalFile::open(&cx, f).expect("reopen");
4533            assert_eq!(
4534                wal_reopened.frame_count(),
4535                4,
4536                "reopen {reopen}: committed frame count must be 4"
4537            );
4538            let (_, data) = wal_reopened.read_frame(&cx, 3).expect("read frame 4");
4539            assert_eq!(
4540                data,
4541                sample_page(0xDD),
4542                "reopen {reopen}: frame 4 content must be intact"
4543            );
4544            wal_reopened.close(&cx).expect("close");
4545        }
4546    }
4547
4548    #[test]
4549    fn wal_generation_identity_from_header_and_eq() {
4550        let header = WalHeader {
4551            magic: WAL_MAGIC_LE,
4552            format_version: WAL_FORMAT_VERSION,
4553            page_size: PAGE_SIZE,
4554            checkpoint_seq: 7,
4555            salts: test_salts(),
4556            checksum: SqliteWalChecksum { s1: 0, s2: 0 },
4557        };
4558        let identity = WalGenerationIdentity::from_header(&header);
4559        assert_eq!(identity.checkpoint_seq, 7);
4560        assert_eq!(identity.salts, test_salts());
4561        let copied = identity;
4562        assert_eq!(copied, identity);
4563        let other = WalGenerationIdentity {
4564            checkpoint_seq: 8,
4565            salts: test_salts(),
4566        };
4567        assert_ne!(identity, other);
4568        let dbg = format!("{identity:?}");
4569        assert!(dbg.contains("WalGenerationIdentity"));
4570    }
4571
4572    #[test]
4573    fn wal_append_frame_ref_debug_clone_copy() {
4574        let data = [0xABu8; 16];
4575        let frame = WalAppendFrameRef {
4576            page_number: 3,
4577            page_data: &data,
4578            db_size_if_commit: 10,
4579        };
4580        let copied = frame;
4581        assert_eq!(copied.page_number, 3);
4582        assert_eq!(copied.db_size_if_commit, 10);
4583        assert_eq!(copied.page_data[0], 0xAB);
4584        let cloned = frame;
4585        assert_eq!(cloned.page_number, frame.page_number);
4586        let dbg = format!("{frame:?}");
4587        assert!(dbg.contains("WalAppendFrameRef"));
4588    }
4589
4590    #[test]
4591    fn wal_file_generation_identity_matches_create_params() {
4592        let cx = test_cx();
4593        let vfs = MemoryVfs::new();
4594        let file = open_wal_file(&vfs, &cx);
4595        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4596        let identity = wal.generation_identity();
4597        assert_eq!(identity.checkpoint_seq, 0);
4598        assert_eq!(identity.salts, test_salts());
4599        wal.close(&cx).expect("close");
4600    }
4601
4602    #[test]
4603    fn wal_file_page_size_and_frame_count_after_create() {
4604        let cx = test_cx();
4605        let vfs = MemoryVfs::new();
4606        let file = open_wal_file(&vfs, &cx);
4607        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4608        assert_eq!(wal.page_size(), PAGE_SIZE as usize);
4609        assert_eq!(wal.frame_count(), 0);
4610        assert!(wal.last_commit_frame(&cx).expect("query").is_none());
4611        wal.append_frame(&cx, 1, &sample_page(1), 5)
4612            .expect("append");
4613        assert_eq!(wal.frame_count(), 1);
4614        assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(0));
4615        wal.close(&cx).expect("close");
4616    }
4617
4618    #[test]
4619    fn wal_file_big_endian_checksum_and_running_checksum() {
4620        let cx = test_cx();
4621        let vfs = MemoryVfs::new();
4622        let file = open_wal_file(&vfs, &cx);
4623        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4624        let _be = wal.big_endian_checksum();
4625        let rc = wal.running_checksum();
4626        assert_eq!(rc.s1.wrapping_add(0), rc.s1);
4627        wal.close(&cx).expect("close");
4628    }
4629
4630    #[test]
4631    fn wal_file_frame_size_equals_header_plus_page() {
4632        let cx = test_cx();
4633        let vfs = MemoryVfs::new();
4634        let file = open_wal_file(&vfs, &cx);
4635        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4636        assert_eq!(wal.frame_size(), WAL_FRAME_HEADER_SIZE + PAGE_SIZE as usize);
4637        wal.close(&cx).expect("close");
4638    }
4639
4640    #[test]
4641    fn wal_file_header_accessor() {
4642        let cx = test_cx();
4643        let vfs = MemoryVfs::new();
4644        let file = open_wal_file(&vfs, &cx);
4645        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4646        let hdr = wal.header();
4647        assert_eq!(hdr.page_size, PAGE_SIZE);
4648        assert_eq!(hdr.salts, test_salts());
4649        wal.close(&cx).expect("close");
4650    }
4651
4652    #[test]
4653    fn wal_file_file_and_file_mut_accessors() {
4654        let cx = test_cx();
4655        let vfs = MemoryVfs::new();
4656        let file = open_wal_file(&vfs, &cx);
4657        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4658        let _f_ref = wal.file();
4659        let _f_mut = wal.file_mut();
4660        wal.close(&cx).expect("close");
4661    }
4662
4663    #[test]
4664    fn durable_sync_records_fsynced_frame_count() {
4665        let cx = test_cx();
4666        let vfs = MemoryVfs::new();
4667        let file = open_wal_file(&vfs, &cx);
4668        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4669        assert_eq!(wal.last_fsynced_frame_count(), 0);
4670
4671        let page = vec![0xABu8; PAGE_SIZE as usize];
4672        let frames = [frame_ref(1, &page, 1)];
4673        wal.append_frames(&cx, &frames).expect("append");
4674        assert_eq!(wal.frame_count(), 1);
4675        assert_eq!(wal.last_fsynced_frame_count(), 0);
4676
4677        wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4678            .expect("durable_sync");
4679        assert_eq!(wal.last_fsynced_frame_count(), 1);
4680        wal.close(&cx).expect("close");
4681    }
4682
4683    #[test]
4684    fn assert_publish_safe_passes_after_durable_sync() {
4685        let cx = test_cx();
4686        let vfs = MemoryVfs::new();
4687        let file = open_wal_file(&vfs, &cx);
4688        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4689
4690        let page = vec![0xCDu8; PAGE_SIZE as usize];
4691        let frames = [frame_ref(1, &page, 1)];
4692        wal.append_frames(&cx, &frames).expect("append");
4693        wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4694            .expect("durable_sync");
4695        wal.assert_publish_safe(1)
4696            .expect("should be safe after fsync");
4697        wal.close(&cx).expect("close");
4698    }
4699
4700    #[test]
4701    fn assert_publish_safe_passes_for_zero_frames() {
4702        let cx = test_cx();
4703        let vfs = MemoryVfs::new();
4704        let file = open_wal_file(&vfs, &cx);
4705        let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4706        wal.assert_publish_safe(0).expect("zero frames always safe");
4707        wal.close(&cx).expect("close");
4708    }
4709
4710    #[test]
4711    fn durable_sync_with_data_only_kind() {
4712        let cx = test_cx();
4713        let vfs = MemoryVfs::new();
4714        let file = open_wal_file(&vfs, &cx);
4715        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4716
4717        let page = vec![0xEFu8; PAGE_SIZE as usize];
4718        let frames = [frame_ref(2, &page, 1)];
4719        wal.append_frames(&cx, &frames).expect("append");
4720        wal.durable_sync(&cx, fsqlite_vfs::SyncKind::DataOnly)
4721            .expect("data-only sync");
4722        assert_eq!(wal.last_fsynced_frame_count(), 1);
4723        wal.close(&cx).expect("close");
4724    }
4725
4726    #[test]
4727    fn raw_sync_reset_clears_fsynced_count() {
4728        let cx = test_cx();
4729        let vfs = MemoryVfs::new();
4730        let file = open_wal_file(&vfs, &cx);
4731        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4732
4733        let page = vec![0x11u8; PAGE_SIZE as usize];
4734        let frames = [frame_ref(1, &page, 1)];
4735        wal.append_frames(&cx, &frames).expect("append");
4736        wal.sync(&cx, SyncFlags::NORMAL).expect("sync");
4737        assert_eq!(wal.last_fsynced_frame_count(), 1);
4738
4739        let new_salts = WalSalts {
4740            salt1: 0x1111_1111,
4741            salt2: 0x2222_2222,
4742        };
4743        wal.reset(&cx, 1, new_salts, true).expect("reset");
4744        assert_eq!(wal.last_fsynced_frame_count(), 0);
4745        wal.close(&cx).expect("close");
4746    }
4747
4748    #[test]
4749    fn opened_wal_has_fsynced_count_equal_to_frame_count() {
4750        let cx = test_cx();
4751        let vfs = MemoryVfs::new();
4752        let file = open_wal_file(&vfs, &cx);
4753        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4754
4755        let page = vec![0x22u8; PAGE_SIZE as usize];
4756        let frames = [frame_ref(1, &page, 1)];
4757        wal.append_frames(&cx, &frames).expect("append");
4758        wal.durable_sync(&cx, fsqlite_vfs::SyncKind::FullDurable)
4759            .expect("durable_sync");
4760        wal.close(&cx).expect("close");
4761
4762        let file2 = open_wal_file(&vfs, &cx);
4763        let wal2 = WalFile::open(&cx, file2).expect("open");
4764        assert_eq!(wal2.frame_count(), 1);
4765        assert_eq!(
4766            wal2.last_fsynced_frame_count(),
4767            wal2.frame_count(),
4768            "opened WAL assumes existing frames are durable"
4769        );
4770        wal2.close(&cx).expect("close");
4771    }
4772
4773    #[test]
4774    fn crash_before_wal_frame_append_preserves_existing_frames() {
4775        let _guard = FAULT_TEST_LOCK.lock().unwrap();
4776        let cx = test_cx();
4777        let vfs = MemoryVfs::new();
4778        let file = open_wal_file(&vfs, &cx);
4779        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4780
4781        let p1 = sample_page(0xAA);
4782        let first_frames = [frame_ref(1, &p1, 1)];
4783        wal.append_frames(&cx, &first_frames).expect("first append");
4784        wal.durable_sync(&cx, SyncKind::FullDurable)
4785            .expect("sync first frame");
4786        assert_eq!(wal.frame_count(), 1);
4787
4788        crate::fault_hooks::arm_crash_boundary(
4789            crate::fault_hooks::CrashBoundary::BeforeWalFrameAppend,
4790            crate::fault_hooks::FaultHookArm::new(
4791                "crash-before-append",
4792                "WAL-FRAME-APPEND-CRASH",
4793                "test_crash_before_append",
4794            ),
4795        );
4796
4797        let p2 = sample_page(0xBB);
4798        let second_frames = [frame_ref(2, &p2, 2)];
4799        let err = wal
4800            .append_frames(&cx, &second_frames)
4801            .expect_err("should fail at crash boundary");
4802        assert!(
4803            err.to_string().contains("fault_inject"),
4804            "error identifies the fault hook: {err}"
4805        );
4806
4807        crate::fault_hooks::clear_crash_boundary();
4808
4809        wal.close(&cx).expect("close after crash");
4810
4811        let file2 = open_wal_file(&vfs, &cx);
4812        let recovered = WalFile::open(&cx, file2).expect("reopen");
4813        assert_eq!(
4814            recovered.frame_count(),
4815            1,
4816            "only the first committed frame survives; the second was never written"
4817        );
4818        recovered.close(&cx).expect("close recovered");
4819    }
4820
4821    #[test]
4822    fn crash_after_fsync_before_publish_leaves_frames_durable() {
4823        let _guard = FAULT_TEST_LOCK.lock().unwrap();
4824        let cx = test_cx();
4825        let vfs = MemoryVfs::new();
4826        let file = open_wal_file(&vfs, &cx);
4827        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4828
4829        let p1 = sample_page(0xCC);
4830        let frames = [frame_ref(1, &p1, 1)];
4831        wal.append_frames(&cx, &frames).expect("append");
4832
4833        crate::fault_hooks::arm_crash_boundary(
4834            crate::fault_hooks::CrashBoundary::AfterFsyncBeforePublish,
4835            crate::fault_hooks::FaultHookArm::new(
4836                "crash-after-fsync",
4837                "WAL-FSYNC-PUBLISH-CRASH",
4838                "test_crash_after_fsync",
4839            ),
4840        );
4841
4842        let err = wal
4843            .durable_sync(&cx, SyncKind::FullDurable)
4844            .expect_err("should fail at crash boundary");
4845        assert!(
4846            err.to_string().contains("fault_inject"),
4847            "error identifies the fault hook: {err}"
4848        );
4849
4850        crate::fault_hooks::clear_crash_boundary();
4851
4852        wal.close(&cx).expect("close after crash");
4853
4854        let file2 = open_wal_file(&vfs, &cx);
4855        let recovered = WalFile::open(&cx, file2).expect("reopen");
4856        assert_eq!(
4857            recovered.frame_count(),
4858            1,
4859            "frame was fsynced before crash so it survives recovery"
4860        );
4861        recovered.close(&cx).expect("close recovered");
4862    }
4863
4864    #[test]
4865    fn crash_before_wal_header_write_on_reset_preserves_old_generation() {
4866        let _guard = FAULT_TEST_LOCK.lock().unwrap();
4867        let cx = test_cx();
4868        let vfs = MemoryVfs::new();
4869        let file = open_wal_file(&vfs, &cx);
4870        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4871
4872        let p1 = sample_page(0xDD);
4873        let frames = [frame_ref(1, &p1, 1)];
4874        wal.append_frames(&cx, &frames).expect("append");
4875        wal.durable_sync(&cx, SyncKind::FullDurable).expect("sync");
4876        assert_eq!(wal.frame_count(), 1);
4877
4878        let original_salts = wal.generation_identity().salts;
4879
4880        crate::fault_hooks::arm_crash_boundary(
4881            crate::fault_hooks::CrashBoundary::BeforeWalHeaderWrite,
4882            crate::fault_hooks::FaultHookArm::new(
4883                "crash-before-header",
4884                "WAL-HEADER-WRITE-CRASH",
4885                "test_crash_before_header",
4886            ),
4887        );
4888
4889        let new_salts = WalSalts {
4890            salt1: original_salts.salt1.wrapping_add(1),
4891            salt2: original_salts.salt2.wrapping_add(1),
4892        };
4893        let err = wal
4894            .reset(&cx, 1, new_salts, true)
4895            .expect_err("should fail at crash boundary");
4896        assert!(
4897            err.to_string().contains("fault_inject"),
4898            "error identifies the fault hook: {err}"
4899        );
4900
4901        crate::fault_hooks::clear_crash_boundary();
4902
4903        wal.close(&cx).expect("close after crash");
4904
4905        let file2 = open_wal_file(&vfs, &cx);
4906        let recovered = WalFile::open(&cx, file2).expect("reopen");
4907        assert_eq!(
4908            recovered.frame_count(),
4909            1,
4910            "header was never rewritten so old generation with 1 frame persists"
4911        );
4912        assert_eq!(
4913            recovered.generation_identity().salts,
4914            original_salts,
4915            "salts unchanged — reset never wrote new header"
4916        );
4917        recovered.close(&cx).expect("close recovered");
4918    }
4919
4920    #[test]
4921    fn crash_after_frame_append_before_fsync_frames_on_disk_but_not_durable() {
4922        let _guard = FAULT_TEST_LOCK.lock().unwrap();
4923        let cx = test_cx();
4924        let vfs = MemoryVfs::new();
4925        let file = open_wal_file(&vfs, &cx);
4926        let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create");
4927
4928        crate::fault_hooks::arm_crash_boundary(
4929            crate::fault_hooks::CrashBoundary::AfterWalFrameAppendBeforeFsync,
4930            crate::fault_hooks::FaultHookArm::new(
4931                "crash-after-append",
4932                "WAL-APPEND-FSYNC-CRASH",
4933                "test_crash_after_append_before_fsync",
4934            ),
4935        );
4936
4937        let p1 = sample_page(0xEE);
4938        let frames = [frame_ref(1, &p1, 1)];
4939        let err = wal
4940            .append_frames(&cx, &frames)
4941            .expect_err("should fail after append but before fsync");
4942        assert!(
4943            err.to_string().contains("fault_inject"),
4944            "error identifies the fault hook: {err}"
4945        );
4946
4947        crate::fault_hooks::clear_crash_boundary();
4948
4949        assert_eq!(
4950            wal.last_fsynced_frame_count(),
4951            0,
4952            "no fsync happened so fsynced count is still zero"
4953        );
4954
4955        wal.close(&cx).expect("close after crash");
4956
4957        let file2 = open_wal_file(&vfs, &cx);
4958        let recovered = WalFile::open(&cx, file2).expect("reopen");
4959        assert!(
4960            recovered.frame_count() <= 1,
4961            "frame may or may not survive recovery depending on MemoryVfs behavior (data written but not fsynced)"
4962        );
4963        recovered.close(&cx).expect("close recovered");
4964    }
4965}