לדלג לתוכן

9.3 - מסגור הודעות וריבוי בקשות בפרוטוקול - הרצאה

הזרם אינו יודע מהי בקשה

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

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

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

אורך הוא קלט לא מהימן

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

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

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

מפענח שמתקדם בית אחד בכל פעם

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

const HEADER: usize = 20;
const MAX_BODY: usize = 4096;

#[derive(Debug, PartialEq, Eq)]
struct Frame { kind: u8, id: u64, body: Vec<u8> }

struct Decoder {
    header: [u8; HEADER],
    used: usize,
    expected: Option<usize>,
    kind: u8,
    id: u64,
    body: Vec<u8>,
    failed: bool,
}

impl Decoder {
    fn new() -> Self {
        Self { header: [0; HEADER], used: 0, expected: None,
               kind: 0, id: 0, body: Vec::new(), failed: false }
    }

    fn complete(&mut self) -> Frame {
        self.used = 0;
        self.expected = None;
        Frame { kind: self.kind, id: self.id, body: std::mem::take(&mut self.body) }
    }

    fn push(&mut self, byte: u8) -> Result<Option<Frame>, &'static str> {
        if self.failed { return Err("decoder already failed"); }
        let result = self.push_inner(byte);
        if result.is_err() { self.failed = true; }
        result
    }

    fn push_inner(&mut self, byte: u8) -> Result<Option<Frame>, &'static str> {
        if let Some(expected) = self.expected {
            self.body.push(byte);
            return Ok(if self.body.len() == expected { Some(self.complete()) } else { None });
        }
        self.header[self.used] = byte;
        self.used += 1;
        if self.used != HEADER { return Ok(None); }
        if &self.header[..4] != b"AMT\0" { return Err("bad magic"); }
        if self.header[4] != 1 { return Err("unsupported version"); }
        if !(1..=3).contains(&self.header[5]) { return Err("unknown kind"); }
        if self.header[6..8] != [0, 0] { return Err("reserved bits set"); }
        self.kind = self.header[5];
        self.id = u64::from_be_bytes(self.header[8..16].try_into().unwrap());
        let length = u32::from_be_bytes(self.header[16..20].try_into().unwrap());
        if length > MAX_BODY as u32 { return Err("body too large"); }
        let expected = length as usize;
        self.expected = Some(expected);
        self.body = Vec::with_capacity(expected);
        Ok(if expected == 0 { Some(self.complete()) } else { None })
    }

    fn finish(&self) -> Result<(), &'static str> {
        if self.failed { return Err("decoder failed"); }
        if self.used != 0 || self.expected.is_some() { return Err("truncated frame"); }
        Ok(())
    }
}

fn encode(kind: u8, id: u64, body: &[u8]) -> Vec<u8> {
    assert!(body.len() <= MAX_BODY);
    let mut bytes = b"AMT\0".to_vec();
    bytes.extend_from_slice(&[1, kind, 0, 0]);
    bytes.extend_from_slice(&id.to_be_bytes());
    bytes.extend_from_slice(&(body.len() as u32).to_be_bytes());
    bytes.extend_from_slice(body);
    bytes
}

fn main() {
    let mut wire = encode(1, 7, b"hello");
    wire.extend(encode(3, 8, b""));
    for split in 0..=wire.len() {
        let mut decoder = Decoder::new();
        let mut frames = Vec::new();
        for chunk in [&wire[..split], &wire[split..]] {
            for &byte in chunk {
                if let Some(frame) = decoder.push(byte).unwrap() { frames.push(frame); }
            }
        }
        decoder.finish().unwrap();
        assert_eq!(frames, vec![Frame { kind: 1, id: 7, body: b"hello".to_vec() },
                                Frame { kind: 3, id: 8, body: vec![] }]);
    }
    println!("all two-chunk splits decoded identically");
}

הפקודות הבאות בונות את הניסוי ללא תלויות חיצוניות.

rustc --edition=2024 framing.rs -o framing
./framing

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

סוף זרם באמצע מבנה

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

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

כמה בקשות על אותו חיבור

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

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

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

מועדים אינם מועברים בלי הקשר

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

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

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