לדלג לתוכן

בדיקות מערכת וסקירת אינווריאנטים

בדיקה שמחזירה תשובה נכונה פעם אחת אינה מספיקה לשירות שמבטיח התאוששות, ביטול ובעלות. נרצה לדעת איזו טענה כל בדיקה יכולה להפריך, ואיזו טענה דורשת גם סקירת קוד. נתחיל מפרסר קטן כדי לראות את ההבדל בין דוגמת הצלחה, בדיקת גבולות ותכונה כללית.

הפורמט בניסוי מכיל אורך u16 ב־big-endian ואחריו payload, עם תקרה של 256 byte-ים. הפונקציה מחזירה תצוגה מושאלת ואת מספר ה־byte-ים שנצרכו, כך שאפשר להשתמש בה גם בזרם שמכיל כמה הודעות. הקוד הוא קובץ ספרייה מלא עם בדיקות שניתן להריץ באמצעות cargo test.

#[derive(Debug, PartialEq)]
pub enum ParseError { NeedMore, TooLarge }
pub fn frame(input: &[u8]) -> Result<(&[u8], usize), ParseError> {
    if input.len() < 2 { return Err(ParseError::NeedMore); }
    let length = u16::from_be_bytes([input[0], input[1]]) as usize;
    if length > 256 { return Err(ParseError::TooLarge); }
    if input.len() - 2 < length { return Err(ParseError::NeedMore); }
    Ok((&input[2..2 + length], 2 + length))
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn every_truncation_is_incomplete() {
        let packet = [0, 3, 10, 20, 30];
        for cut in 0..packet.len() {
            assert_eq!(frame(&packet[..cut]), Err(ParseError::NeedMore));
        }
        assert_eq!(frame(&packet), Ok((&packet[2..], 5)));
    }
    #[test]
    fn extra_bytes_belong_to_next_frame() {
        assert_eq!(frame(&[0, 1, 9, 88]), Ok((&[9][..], 3)));
    }
    #[test]
    fn reject_before_body_arrives() {
        assert_eq!(frame(&[1, 1]), Err(ParseError::TooLarge));
    }
}

הבדיקה האחרונה חשובה לא רק לתקינות הפרסר אלא גם למשאבים: היא מוכיחה שאין צורך להמתין ל־257 byte-ים כדי לדחות אורך אסור. בפרסר של שרת נוודא גם שאין allocation לפי אורך לפני הבדיקה. הבדיקה של כל prefix מבוססת על תכונת הפרוטוקול, ולא על העתקה של ענפי המימוש לתוך מבחן שמחושב באותה דרך.

נעלה לרמת האחסון. נזריק כתיבה קצרה, EINTR, ENOSPC וכשל sync בכל גבול, ונבדוק ש־recovery מחזיר מצב חוקי. עצירת process אינה זהה לאובדן חשמל: page cache של ה־kernel יכול להמשיך להיכתב אחרי שהרגנו את התוכנית. לכן דוח crash testing יפריד בין process kill, הפסקת VM ושימוש בכלי fault injection של מערכת האחסון. אף אחד מהם אינו מבטל את הצורך להבין מה ההתקן מבטיח.

במקביליות נבדוק interleavings עם Loom עבור המודל המצומצם שמתאים לכלי, ונריץ stress על המימוש המלא. המודל צריך לכלול את אותם יחסי סנכרון מהותיים; החלפת atomics במנעול בתוך המודל יכולה להעלים בדיוק את המרוץ שרצינו לבדוק. ה־Miri וכלי sanitizers מחפשים מחלקות שונות של כשלים, והעדר ממצא אינו הוכחה של כל חוזה unsafe.

ה־fuzzing מתאים במיוחד ל־parser, לפורמט journal ול־FFI עם קלט מוגדר. נגדיר תקרה למשאבים כדי שקלט גדול לא יהפוך את ה־fuzzer לבדיקת OOM בלבד, ונשמור corpus של מקרים שהפילו גרסאות קודמות. סביבת cargo-fuzz וכלים מסוימים דורשת nightly או toolchain ייעודי; הקוד שמופץ נשאר stable אלא אם החלטנו אחרת במפורש.

בסקירת אינווריאנטים נכתוב עבור כל unsafe block מאיפה הגיע pointer, מה אורך האזור, מי רשאי לשנותו ומה מסיים את חיי ההשאלה. עבור teardown נבדוק שלא מגיע callback אחרי שחרור context. עבור ABI נשווה גדלים וגרסאות בשני הצדדים. התיעוד של Loom ושל Miri מסביר את תחום הכלים, וההגשה תציין במפורש אילו טענות נשארות מבוססות סקירה ומפרט.

ניקח invariant שלפיו שום reply מוצלח אינו נשלח לפני commit. בדיקת unit של encoder אינה נוגעת בו, ובדיקת עומס שמקבלת אלף תשובות תקינות אינה מכריחה את חלון הכשל להופיע. ניצור במקום זאת אחסון בדיקה עם barrier לפני sync: השרת מגיע אליו, והבדיקה מאמתת שהלקוח עדיין לא קיבל תשובה. אחר כך נשחרר את ה־barrier להצלחה או לכשל ונבדוק את התוצאה המתאימה. זהו ניסוי שמכוון ליחס סדר מסוים. בבדיקה מול File אמיתי נשלים את התמונה עם recovery, אבל לא נוותר על הניסוי המבודד שמאפשר לשלוט בדיוק בנקודת ההתחייבות.