חקירה והגנת תכנון¶
נניח שהמשתמש קיבל תוצאה של עבודה אחרת. אין crash, אין שגיאת רשת והבדיקות הרגילות עוברות. זאת תקלה נוחה במיוחד להסתתר בה, כי כל שכבה יכולה להראות שהיא ביצעה פעולה חוקית מבחינתה.
נתחיל מחבילת ראיות קטנה שנוצרה לצורך התרגיל. הזמנים הבאים הם נתונים סינתטיים, כדי שנוכל לבדוק הסברים בלי להעמיד פנים שאספנו אותם ממוצר אמיתי.
| הזמן במילישניות | האירוע |
|---|---|
| בשנייה הראשונה, נקודה 0 | התחלת עבודה 101 בתוך slot מספר 3 |
| אחרי 50 | הלקוח הפסיק להמתין לעבודה 101 |
| אחרי 51 | התחלת עבודה 102 בתוך אותו slot |
| אחרי 60 | הגיעה השלמה של ניסיון העבודה הראשון |
| אחרי 61 | הממשק הציג לעבודה 102 ערך ששייך לעבודה 101 |
הטבלה אינה מוכיחה עדיין מי כתב את הערך. היא מצמצמת את השאלות: מה מזהה completion, מתי slot ניתן למחזור ואיזה אירוע הפך אותו לפנוי.
בונים הסבר שאפשר להפריך¶
הסבר אחד הוא שהtransport החליף bytes בין בקשות. הסבר אחר הוא שהbytes הגיעו נכון, אבל הcallback קרא מזהה שכבר הוחלף. כדי להבדיל ביניהם צריך לשמור מזהה ניסיון בזמן submit ולבדוק אותו בזמן completion, לצד זהות האובייקט שאליו נכתבה התוצאה.
הקוד הבא משחזר את ההסבר השני בלי threads ובלי undefined behavior. התזמון נקבע ידנית, כך שהבאג אינו תלוי במזל.
#include <cassert>
#include <cstdint>
#include <iostream>
struct Slot {
std::uint64_t request = 0;
bool busy = false;
int result = 0;
};
void begin(Slot& slot, std::uint64_t id) {
assert(!slot.busy);
slot = {id,true,0};
}
void client_timeout(Slot& slot) { slot.busy = false; }
void completion(Slot* context, int value) {
context->result = value;
context->busy = false;
std::cout << "reply id=" << context->request << " value=" << value << '\n';
}
int main() {
Slot slot;
begin(slot,101);
Slot* old_context = &slot;
client_timeout(slot);
begin(slot,102);
completion(old_context,111);
assert(slot.request == 102 && slot.result == 111);
}
הassert האחרון מוכיח את הבאג במודל, ולכן הצלחת התוכנית אינה הצלחת המוצר. הpointer עדיין מצביע לאותו אובייקט חוקי בזיכרון, אבל המשמעות של האובייקט השתנתה. כלי שמחפש רק גישה לזיכרון משוחרר לא בהכרח יתפוס את הטעות הזאת.
מזהה ניסיון ובעלות על completion¶
הפתרון מתחיל באובייקט פעולה שאינו מוחלף כל עוד אפשר לקבל עבורו completion. הtimeout של הclient מסמן שהמתנה מסוימת הסתיימה, אך אינו מסיים את חיי הפעולה. בקשת cancel נשמרת בנפרד, והמשאב משתחרר לאחר ההכרעה הסופית של הAPI שמחזיק אותו.
אפשר להוסיף generation לslot, אבל אז צריך לבדוק שני דברים. ראשית, completion חייב לשאת את הgeneration שנלכד בזמן submit, ולא לקרוא אותו מהslot החדש. שנית, גם completion שנדחה כישן עשוי עדיין להיות אחראי לשחרור משאב ישן. בדיקת מספר לבדה אינה תוכנית cleanup.
איך משתמשים בdump ובtrace¶
הdump יכול להראות שהslot כבר מצביע לעבודה 102. הוא לא בהכרח מכיל את הרגע שבו 101 נשלחה. הtrace יכול להראות את שני האירועים, אבל רק אם שמרנו מזהי ניסיון וזהות context מתאימים ולא איבדנו את הרשומות.
נרצה לשמור את build ID, הקלט, זהות הניסיון ושלבי הבעלות. כתובת pointer יכולה לעזור בחקירה מקומית, אך אינה מזהה קבוע בין הרצות וצריך לשקול אם בכלל לשמור אותה בחבילת אבחון שנמסרת החוצה. מזהה פעולה מפורש לרוב מסביר יותר.
החקירה אינה מסתיימת במשפט ״מצאנו race״. צריך לציין איזה invariant הופר: slot הוכרז פנוי לפני שמקור הcompletion סיים להשתמש בcontext שלו. התיקון צריך לשנות את המעבר הזה, והבדיקה צריכה לכפות את הסדר שבו הוא נשבר.
מגינים על התכנון דרך שינוי¶
אחרי התיקון נוסיף דרישה: המשתמש יכול לבטל ולשלוח עבודה חדשה מיד, והישנה עשויה להשלים מאוחר. אם התכנון נכון, הוא צריך להסביר את שני החיים במקביל בלי Sleep ובלי איסור מקרי על לחיצה מהירה.
במבחן ההגנה מקבלים dump או trace חדש בלי שם הבאג. מתחילים בהשערות, מבקשים ראיות ומעדכנים את ההסבר כשהן אינן מתאימות. מותר לומר שעדיין חסר מידע; אסור להפוך ניחוש לתוצאת חקירה רק כדי לסיים מהר.
התוצר הוא repro קטן, תיקון, regression test והסבר של מה הבדיקה אינה מכסה. אלה ארבעה חלקים של אותה עבודה, כי בלי repro קשה להוכיח קשר, ובלי בדיקת חזרה קל לשבור אותו שוב בשינוי הבא.