Skip to main content

nodedb_wal/
reader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! WAL reader for crash recovery and replay.
4//!
5//! Reads records sequentially from a WAL file, validating checksums and
6//! magic numbers. Stops at the first corruption point — everything before
7//! that point is the committed prefix.
8//!
9//! ## Replay invariants
10//!
11//! - Replay is **deterministic**: the same WAL file always produces the
12//!   same sequence of records.
13//! - Replay is **idempotent**: replaying the same record twice has the
14//!   same effect as replaying it once.
15//! - Unknown optional record types (bit 15 clear) are skipped.
16//! - Unknown required record types (bit 15 set) cause a replay failure.
17//!
18//! ## Encryption
19//!
20//! [`WalReader::open`] is the replay constructor: it takes the key ring and
21//! hands out plaintext, so a consumer cannot act on ciphertext by forgetting to
22//! decrypt. [`WalReader::open_raw`] is the opt-in structural constructor for
23//! code that only walks record framing.
24
25use std::fs::File;
26use std::io::{Read, Seek, SeekFrom};
27use std::path::Path;
28
29use crate::crypto::KeyRing;
30use crate::error::{Result, WalError};
31use crate::preamble::{SegmentPreamble, WAL_PREAMBLE_MAGIC, read_leading_preamble};
32use crate::record::foreign_version::read_declared_payload;
33use crate::record::{
34    HEADER_SIZE, RecordHeader, RecordType, WalRecord, is_format_gap, may_be_format_gap,
35};
36use crate::segment::SegmentDecryptor;
37
38fn checked_offset_add(offset: u64, len: usize) -> Result<u64> {
39    let len = u64::try_from(len).map_err(|_| {
40        WalError::Io(std::io::Error::new(
41            std::io::ErrorKind::InvalidData,
42            "WAL read length does not fit u64",
43        ))
44    })?;
45    offset.checked_add(len).ok_or_else(|| {
46        WalError::Io(std::io::Error::new(
47            std::io::ErrorKind::InvalidData,
48            "WAL read offset overflow",
49        ))
50    })
51}
52
53/// Why a reader stopped producing records.
54///
55/// `Ok(None)` alone cannot distinguish "the segment ends here" from "the bytes
56/// here are damaged". The difference decides whether the records that were
57/// read are the whole committed prefix or only the part before a hole, so
58/// readers record it and callers classify it via [`crate::torn_tail`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StopReason {
61    /// The segment ended cleanly on a record boundary.
62    Eof,
63
64    /// A record at this offset failed structural validation (bad magic, a
65    /// zeroed format version, a short read, a checksum mismatch, or a format
66    /// version this build cannot read found after the segment's first record).
67    Corruption { offset: u64 },
68}
69
70/// Sequential WAL reader.
71pub struct WalReader {
72    file: File,
73    offset: u64,
74    /// Offset of the segment's first record, past any preamble. The format
75    /// version is judged here and nowhere else: one writer never mixes formats
76    /// inside one segment.
77    records_start: u64,
78    /// Preamble read from offset 0 of this segment (present when encryption
79    /// is active). The epoch is used as part of the AAD for decryption.
80    segment_preamble: Option<SegmentPreamble>,
81    /// Optional double-write buffer for torn write recovery.
82    double_write: Option<crate::double_write::DoubleWriteBuffer>,
83    /// Why iteration stopped, once it has.
84    stop_reason: Option<StopReason>,
85    /// End of the last fully consumed record, padding included.
86    committed_end: u64,
87    /// Applied to every record before it is surfaced. `None` only in raw
88    /// structural mode, where payloads may still be ciphertext.
89    decryptor: Option<SegmentDecryptor>,
90}
91
92impl WalReader {
93    /// Open a WAL file for replay.
94    ///
95    /// Every record is decrypted on the way out: what the caller receives never
96    /// has `ENCRYPTED_FLAG` set, and its checksum verifies against the
97    /// plaintext. `keys` must be the key ring the segment was written under, or
98    /// `None` for a segment that was never encrypted — an encrypted record met
99    /// with no key is [`WalError::EncryptedRecordWithoutKey`], never ciphertext
100    /// handed to the caller.
101    pub fn open(path: &Path, keys: Option<&KeyRing>) -> Result<Self> {
102        let mut reader = Self::open_raw(path)?;
103        reader.decryptor = Some(SegmentDecryptor::new(
104            reader.segment_preamble.as_ref(),
105            keys,
106        ));
107        Ok(reader)
108    }
109
110    /// Open a WAL file for structural scanning only.
111    ///
112    /// **Payloads may be ciphertext.** Records come out exactly as they are on
113    /// disk, `ENCRYPTED_FLAG` and all. This exists for code that reads nothing
114    /// but record framing — LSNs, lengths, offsets — and specifically for
115    /// [`crate::recovery::recover`], which runs from `WalWriter::open` where no
116    /// key ring exists and resuming an encrypted segment for appending must
117    /// still work. Never use it for replay.
118    ///
119    /// If the file begins with a valid `WALP` preamble (16 bytes), it is
120    /// consumed and stored for use as AAD during decryption. Files without a
121    /// preamble (unencrypted segments) start reading from offset 0 directly.
122    ///
123    /// Automatically opens the companion double-write buffer file
124    /// (`*.dwb`) if it exists alongside the WAL file.
125    pub fn open_raw(path: &Path) -> Result<Self> {
126        let mut file = File::open(path)?;
127        let dwb_path = path.with_extension("dwb");
128        let double_write = if dwb_path.exists() {
129            crate::double_write::DoubleWriteBuffer::open(
130                &dwb_path,
131                crate::double_write::DwbMode::Buffered,
132            )
133            .ok()
134        } else {
135            None
136        };
137
138        let (segment_preamble, start_offset) =
139            read_leading_preamble(&mut file, &WAL_PREAMBLE_MAGIC)?;
140
141        Ok(Self {
142            file,
143            offset: start_offset,
144            records_start: start_offset,
145            segment_preamble,
146            double_write,
147            stop_reason: None,
148            committed_end: start_offset,
149            decryptor: None,
150        })
151    }
152
153    /// Hand a record to the caller, decrypting it first unless this reader was
154    /// opened in raw structural mode.
155    fn deliver(&self, record: WalRecord) -> Result<Option<WalRecord>> {
156        match &self.decryptor {
157            Some(decryptor) => decryptor.decrypt_record(record).map(Some),
158            None => Ok(Some(record)),
159        }
160    }
161
162    /// Byte offset just past the last fully consumed record.
163    ///
164    /// This is where a writer resumes appending. It is not the same as
165    /// [`Self::offset`]: it includes any alignment padding that followed the
166    /// final record, and it excludes a partially-read damaged record whose
167    /// header the reader had to consume before rejecting it.
168    pub fn committed_end(&self) -> u64 {
169        self.committed_end
170    }
171
172    /// Why iteration stopped, or `None` while records are still being read.
173    ///
174    /// [`StopReason::Corruption`] does not by itself mean data was lost — it
175    /// is the input to [`crate::torn_tail::verify_committed_prefix`], which
176    /// decides whether the damage is an unfsynced tail or a hole with
177    /// committed records behind it.
178    pub fn stop_reason(&self) -> Option<StopReason> {
179        self.stop_reason
180    }
181
182    fn stop(&mut self, reason: StopReason) -> Result<Option<WalRecord>> {
183        self.stop_reason = Some(reason);
184        Ok(None)
185    }
186
187    /// The preamble read from this segment file, if present.
188    ///
189    /// Returns `None` for unencrypted segments (no preamble written).
190    pub fn segment_preamble(&self) -> Option<&SegmentPreamble> {
191        self.segment_preamble.as_ref()
192    }
193
194    /// Read the next record from the WAL.
195    ///
196    /// Returns `None` at EOF (clean end) or at the first corruption point.
197    /// Returns `Err` for I/O errors, unknown required record types, an
198    /// oversized payload declaration, and a format version this build cannot
199    /// read at the segment's first record. A reader that returns `Err` for a
200    /// header has consumed that header, so a caller must stop at the `Err`.
201    pub fn next_record(&mut self) -> Result<Option<WalRecord>> {
202        loop {
203            // Read header.
204            let record_offset = self.offset;
205            let mut header_buf = [0u8; HEADER_SIZE];
206            match self.read_exact(&mut header_buf) {
207                Ok(()) => {}
208                Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
209                    // A short trailing header is either a clean end or a torn
210                    // tail; both are the boundary of the committed prefix.
211                    let reason = if record_offset == self.file_len()? {
212                        StopReason::Eof
213                    } else {
214                        StopReason::Corruption {
215                            offset: record_offset,
216                        }
217                    };
218                    return self.stop(reason);
219                }
220                Err(e) => return Err(e),
221            }
222
223            let header = RecordHeader::from_bytes(&header_buf);
224
225            // Validate before using the attacker-controlled payload length for
226            // allocation. Malformed oversized declarations are explicit errors;
227            // ordinary corruption remains the end of the committed prefix.
228            let header_offset = self
229                .offset
230                .checked_sub(u64::try_from(HEADER_SIZE).map_err(|_| {
231                    WalError::Io(std::io::Error::new(
232                        std::io::ErrorKind::InvalidData,
233                        "WAL header size does not fit u64",
234                    ))
235                })?)
236                .ok_or_else(|| {
237                    WalError::Io(std::io::Error::new(
238                        std::io::ErrorKind::InvalidData,
239                        "WAL header offset underflow",
240                    ))
241                })?;
242            match header.validate(header_offset) {
243                Ok(()) => {}
244                Err(error @ WalError::PayloadTooLarge { .. }) => return Err(error),
245                // Another build's format travels out so the refusal can name
246                // the version. A damaged header in this build's format is a
247                // stop for `torn_tail` to classify, like any damage.
248                Err(error @ WalError::UnsupportedVersion { .. })
249                    if may_be_format_gap(&header, header_offset == self.records_start) =>
250                {
251                    let payload = read_declared_payload(&mut self.file, &header, self.offset)?;
252                    if is_format_gap(&header, payload.as_deref()) {
253                        return Err(error);
254                    }
255                    return self.stop(StopReason::Corruption {
256                        offset: header_offset,
257                    });
258                }
259                Err(_) => {
260                    return self.stop(StopReason::Corruption {
261                        offset: header_offset,
262                    });
263                }
264            }
265
266            // Read payload only after the bounded header validation above.
267            let payload_len =
268                usize::try_from(header.payload_len).map_err(|_| WalError::PayloadTooLarge {
269                    size: usize::MAX,
270                    max: crate::record::MAX_WAL_PAYLOAD_SIZE,
271                })?;
272            let mut payload = vec![0u8; payload_len];
273            if !payload.is_empty() {
274                match self.read_exact(&mut payload) {
275                    Ok(()) => {}
276                    Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
277                        // The header promised more payload than the file holds.
278                        return self.stop(StopReason::Corruption {
279                            offset: header_offset,
280                        });
281                    }
282                    Err(e) => return Err(e),
283                }
284            }
285
286            let record = WalRecord { header, payload };
287
288            // Verify checksum.
289            if record.verify_checksum().is_err() {
290                if let Some(recovered) = self.repair_torn_record(&header, header_offset)? {
291                    return self.deliver(recovered);
292                }
293                return self.stop(StopReason::Corruption {
294                    offset: header_offset,
295                });
296            }
297
298            // The record is intact, so the bytes it occupies are committed
299            // whether or not it is surfaced to the caller.
300            self.committed_end = self.offset;
301
302            // Check if the record type is known (strip encrypted flag for lookup).
303            let logical_type = record.logical_record_type();
304            let Some(kind) = RecordType::from_raw(logical_type) else {
305                if RecordType::is_required(logical_type) {
306                    return Err(WalError::UnknownRequiredRecordType {
307                        record_type: header.record_type,
308                        lsn: header.lsn,
309                    });
310                }
311                // Unknown optional record — skip and continue loop.
312                continue;
313            };
314
315            // Alignment padding is framing, not data — never surface it.
316            if kind == RecordType::Noop {
317                continue;
318            }
319
320            return self.deliver(record);
321        }
322    }
323
324    /// Rebuild a record whose on-disk bytes failed CRC from the double-write
325    /// buffer, if it holds an intact copy.
326    ///
327    /// Also re-anchors the read cursor: the torn header's own `payload_len` is
328    /// not trustworthy, so the next record's position is derived from the DWB's
329    /// authoritative copy rather than from wherever the read of the damaged
330    /// bytes happened to leave the cursor.
331    fn repair_torn_record(
332        &mut self,
333        header: &RecordHeader,
334        header_offset: u64,
335    ) -> Result<Option<WalRecord>> {
336        let Some(dwb) = &mut self.double_write else {
337            return Ok(None);
338        };
339        let Ok(Some(recovered)) = dwb.recover_record(header.lsn) else {
340            return Ok(None);
341        };
342        tracing::info!(
343            lsn = header.lsn,
344            "recovered torn write from double-write buffer"
345        );
346        let resume = checked_offset_add(
347            checked_offset_add(header_offset, HEADER_SIZE)?,
348            recovered.payload.len(),
349        )?;
350        self.file.seek(SeekFrom::Start(resume))?;
351        self.offset = resume;
352        self.committed_end = resume;
353        Ok(Some(recovered))
354    }
355
356    /// Iterator over all valid records in the WAL.
357    pub fn records(self) -> WalRecordIter {
358        WalRecordIter { reader: self }
359    }
360
361    /// Current read offset in the file.
362    pub fn offset(&self) -> u64 {
363        self.offset
364    }
365
366    fn file_len(&self) -> Result<u64> {
367        Ok(self.file.metadata()?.len())
368    }
369
370    fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
371        let next_offset = checked_offset_add(self.offset, buf.len())?;
372        self.file.read_exact(buf)?;
373        self.offset = next_offset;
374        Ok(())
375    }
376}
377
378/// Iterator over WAL records.
379pub struct WalRecordIter {
380    reader: WalReader,
381}
382
383impl Iterator for WalRecordIter {
384    type Item = Result<WalRecord>;
385
386    fn next(&mut self) -> Option<Self::Item> {
387        match self.reader.next_record() {
388            Ok(Some(record)) => Some(Ok(record)),
389            Ok(None) => None,
390            Err(e) => Some(Err(e)),
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::record::RecordType;
399    use crate::writer::WalWriter;
400
401    #[test]
402    fn write_then_read_roundtrip() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("test.wal");
405
406        // Write records.
407        {
408            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
409            writer
410                .append(RecordType::Put as u32, 1, 0, 0, b"first")
411                .unwrap();
412            writer
413                .append(RecordType::Put as u32, 2, 1, 0, b"second")
414                .unwrap();
415            writer
416                .append(RecordType::Delete as u32, 1, 0, 0, b"third")
417                .unwrap();
418            writer.sync().unwrap();
419        }
420
421        // Read them back.
422        let reader = WalReader::open(&path, None).unwrap();
423        let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
424
425        assert_eq!(records.len(), 3);
426        assert_eq!(records[0].header.lsn, 1);
427        assert_eq!(records[0].header.tenant_id, 1);
428        assert_eq!(records[0].payload, b"first");
429
430        assert_eq!(records[1].header.lsn, 2);
431        assert_eq!(records[1].header.tenant_id, 2);
432        assert_eq!(records[1].header.vshard_id, 1);
433        assert_eq!(records[1].payload, b"second");
434
435        assert_eq!(records[2].header.lsn, 3);
436        assert_eq!(records[2].header.record_type, RecordType::Delete as u32);
437        assert_eq!(records[2].payload, b"third");
438    }
439
440    #[test]
441    fn empty_wal_yields_no_records() {
442        let dir = tempfile::tempdir().unwrap();
443        let path = dir.path().join("empty.wal");
444
445        // Create an empty file.
446        {
447            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
448            writer.sync().unwrap();
449        }
450
451        let reader = WalReader::open(&path, None).unwrap();
452        let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
453        assert!(records.is_empty());
454    }
455
456    #[test]
457    fn truncated_file_stops_at_committed_prefix() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join("truncated.wal");
460
461        // Write records.
462        {
463            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
464            writer
465                .append(RecordType::Put as u32, 1, 0, 0, b"good-record")
466                .unwrap();
467            writer.sync().unwrap();
468        }
469
470        // Append garbage (simulating a torn write).
471        {
472            use std::io::Write;
473            let mut file = std::fs::OpenOptions::new()
474                .append(true)
475                .open(&path)
476                .unwrap();
477            file.write_all(b"GARBAGE_PARTIAL_RECORD").unwrap();
478        }
479
480        // Reader should return only the valid record.
481        let reader = WalReader::open(&path, None).unwrap();
482        let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
483        assert_eq!(records.len(), 1);
484        assert_eq!(records[0].payload, b"good-record");
485    }
486
487    #[test]
488    fn oversized_payload_header_is_a_typed_error_before_allocation() {
489        let dir = tempfile::tempdir().unwrap();
490        let path = dir.path().join("oversized.wal");
491        let header = RecordHeader {
492            magic: crate::record::WAL_MAGIC,
493            format_version: crate::record::WAL_FORMAT_VERSION,
494            record_type: RecordType::Put as u32,
495            lsn: 1,
496            tenant_id: 1,
497            vshard_id: 0,
498            payload_len: (crate::record::MAX_WAL_PAYLOAD_SIZE + 1) as u32,
499            database_id: 0,
500            apply_key: 0,
501            event_source: crate::record::NO_EVENT_SOURCE,
502            commit_hlc: 0,
503            crc32c: 0,
504        };
505        std::fs::write(&path, header.to_bytes()).unwrap();
506
507        let mut reader = WalReader::open(&path, None).unwrap();
508        assert!(matches!(
509            reader.next_record(),
510            Err(WalError::PayloadTooLarge { .. })
511        ));
512    }
513
514    #[test]
515    fn checked_offset_add_rejects_overflow() {
516        assert!(checked_offset_add(u64::MAX, 1).is_err());
517    }
518
519    #[test]
520    fn skip_many_unknown_optional_records_is_iterative() {
521        // Record type 99 has bit 15 clear (99 & 0x8000 == 0) and is not a
522        // known variant, so the reader must skip it as an unknown optional.
523        // A recursive skip (`return self.next_record()`) would exhaust the
524        // stack and panic on 50 000 consecutive unknown optional records.
525        // The skip must be a loop, so all 50 000 are skipped without
526        // overflow and the one valid record at the end is returned.
527        const UNKNOWN_OPTIONAL: u32 = 99; // no 0x8000 bit → optional, not in enum
528        const SKIP_COUNT: usize = 50_000;
529
530        let dir = tempfile::tempdir().unwrap();
531        let path = dir.path().join("many_unknown.wal");
532
533        {
534            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
535            for _ in 0..SKIP_COUNT {
536                writer
537                    .append(UNKNOWN_OPTIONAL, 1, 0, 0, b"skip-me")
538                    .unwrap();
539            }
540            writer
541                .append(RecordType::Put as u32, 1, 0, 0, b"keep-me")
542                .unwrap();
543            writer.sync().unwrap();
544        }
545
546        let reader = WalReader::open(&path, None).unwrap();
547        let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
548
549        // Only the single known Put record survives; all unknown optional
550        // records are silently discarded.
551        assert_eq!(records.len(), 1);
552        assert_eq!(records[0].payload, b"keep-me");
553    }
554
555    /// An unknown format version is a typed error, not a silent stop, and a
556    /// zeroed version is a stop rather than an error.
557    ///
558    /// A store written by another build is intact, so its format reaches the
559    /// caller as a version and never as damage. Version zero is the exception:
560    /// no build writes it, so it stays a corruption stop, which `torn_tail`
561    /// already bounds.
562    #[test]
563    fn an_unknown_format_version_is_an_error_and_a_zeroed_one_is_a_stop() {
564        let dir = tempfile::tempdir().unwrap();
565        let framed = dir.path().join("framed.wal");
566        {
567            let mut writer = WalWriter::open_without_direct_io(&framed).unwrap();
568            writer
569                .append(RecordType::Put as u32, 1, 0, 0, b"x")
570                .unwrap();
571            writer.sync().unwrap();
572        }
573
574        let mut bytes = std::fs::read(&framed).unwrap();
575        crate::record::foreign_version::reseal_with_version(
576            &mut bytes,
577            0,
578            crate::record::WAL_FORMAT_VERSION + 1,
579        );
580        let unknown = dir.path().join("unknown.wal");
581        std::fs::write(&unknown, &bytes).unwrap();
582
583        let reader = WalReader::open_raw(&unknown).unwrap();
584        match reader.records().collect::<Result<Vec<_>>>() {
585            Err(WalError::UnsupportedVersion { version, supported }) => {
586                assert_eq!(version, crate::record::WAL_FORMAT_VERSION + 1);
587                assert_eq!(supported, crate::record::WAL_FORMAT_VERSION);
588            }
589            other => panic!("expected UnsupportedVersion, got {other:?}"),
590        }
591
592        // The version field is bytes 4..6 of the record header.
593        bytes[4..6].copy_from_slice(&0u16.to_le_bytes());
594        let zeroed = dir.path().join("zeroed.wal");
595        std::fs::write(&zeroed, &bytes).unwrap();
596
597        let mut reader = WalReader::open_raw(&zeroed).unwrap();
598        let mut records = Vec::new();
599        while let Some(record) = reader
600            .next_record()
601            .expect("a zeroed version stops the stream, it does not fail it")
602        {
603            records.push(record);
604        }
605        assert!(
606            records.is_empty(),
607            "nothing before the stop is a readable record"
608        );
609        // An empty stream is also what a silent skip or a clean end would
610        // return, so the reason has to be asserted for this to prove a stop.
611        assert!(
612            matches!(reader.stop_reason(), Some(StopReason::Corruption { .. })),
613            "a zeroed version must be a corruption stop, not a quiet end: {:?}",
614            reader.stop_reason()
615        );
616    }
617
618    /// A version this build cannot read, found after the segment's first
619    /// record, is damage rather than a format gap.
620    ///
621    /// One writer never mixes formats inside one segment, so a mismatch there
622    /// can only be a broken header. The stream stops and `torn_tail` classifies
623    /// where, instead of the reader declaring a format gap the store does not
624    /// have.
625    #[test]
626    fn a_version_mismatch_after_the_first_record_is_damage() {
627        let dir = tempfile::tempdir().unwrap();
628        let path = dir.path().join("two-records.wal");
629        {
630            let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
631            writer
632                .append(RecordType::Put as u32, 1, 0, 0, b"first")
633                .unwrap();
634            writer
635                .append(RecordType::Put as u32, 1, 0, 0, b"second")
636                .unwrap();
637            writer.sync().unwrap();
638        }
639
640        let mut reader = WalReader::open_raw(&path).unwrap();
641        let first = reader
642            .next_record()
643            .unwrap()
644            .expect("the first record reads");
645        assert_eq!(first.header.lsn, 1, "records before the damage still read");
646        let second_at = reader.committed_end();
647
648        let mut bytes = std::fs::read(&path).unwrap();
649        let version_at = usize::try_from(second_at).unwrap() + 4;
650        bytes[version_at..version_at + 2]
651            .copy_from_slice(&(crate::record::WAL_FORMAT_VERSION + 1).to_le_bytes());
652        std::fs::write(&path, &bytes).unwrap();
653
654        let mut reader = WalReader::open_raw(&path).unwrap();
655        assert!(
656            reader.next_record().unwrap().is_some(),
657            "the first record is still readable"
658        );
659        assert!(
660            reader.next_record().unwrap().is_none(),
661            "the mismatch stops the stream instead of failing the read"
662        );
663        assert!(
664            matches!(reader.stop_reason(), Some(StopReason::Corruption { .. })),
665            "a mismatch after the first record is damage, not a format gap: {:?}",
666            reader.stop_reason()
667        );
668    }
669}