בדיקות מוצר וחבילות אבחון¶
הדוגמה הקטנה עובדת עם קלט אחד, אבל מוצר מקבל קלט חלקי, רצפים לא צפויים וכשל בדיוק בין שתי פעולות. כדי לבדוק אותו צריך להפוך את החוזים שלנו לדברים שאפשר להפר. נתחיל מparser קטן, ואז נוסיף failures של משאבים ובדיקה ארוכה של חיי מערכת.
בדיקה אינה רק בקשה שמצפה לקבל תשובה. היא גם תיאור של מה אסור להשתנות כשבקשה נדחית, מה חייב להשתחרר אחרי ביטול ואיזה מידע צריך להישמר כדי לחקור תקלה במחשב אחר.
מפרידים parser מהsocket¶
נגדיר הודעה עם header של 16 בתים: magic, גרסה, סוג, אורך ומזהה. כל המספרים בlittle endian. הסוג הראשון מחזיק מספר של 32 ביט, והסוג השני מחזיק רצף בתים באורך מוגבל. התוצאה שואלת את הbuffer המקורי, ולכן אינה רשאית לחיות אחריו.
הקובץ wire.h הבא מכיל את הparser ואת הserializer של פורמט המעבדה. הוא אינו מבצע I/O. ההפרדה מאפשרת לבדוק מיליוני קלטים בלי לפתוח מיליוני חיבורים.
#pragma once
#include <cstdint>
#include <optional>
#include <span>
#include <stdexcept>
#include <vector>
struct RequestView {
std::uint16_t kind;
std::uint32_t id;
std::span<const std::uint8_t> payload;
};
inline std::uint16_t load16(std::span<const std::uint8_t> bytes, std::size_t offset) {
return static_cast<std::uint16_t>(bytes[offset]) |
static_cast<std::uint16_t>(bytes[offset + 1]) << 8;
}
inline std::uint32_t load32(std::span<const std::uint8_t> bytes, std::size_t offset) {
return static_cast<std::uint32_t>(bytes[offset]) |
static_cast<std::uint32_t>(bytes[offset + 1]) << 8 |
static_cast<std::uint32_t>(bytes[offset + 2]) << 16 |
static_cast<std::uint32_t>(bytes[offset + 3]) << 24;
}
inline bool valid_payload(std::uint16_t kind, std::size_t size) {
return (kind == 1 && size == 4) || (kind == 2 && size <= 4096);
}
inline std::optional<RequestView> parse(std::span<const std::uint8_t> bytes) {
if (bytes.size() < 16 || bytes.size() > 16 + 4096) return std::nullopt;
if (load32(bytes, 0) != 0x31544d41 || load16(bytes, 4) != 1) return std::nullopt;
const auto kind = load16(bytes, 6);
const auto size = load32(bytes, 8);
if (size != bytes.size() - 16 || !valid_payload(kind, size)) return std::nullopt;
return RequestView{kind, load32(bytes, 12), bytes.subspan(16)};
}
inline std::vector<std::uint8_t> serialize(RequestView request) {
if (!valid_payload(request.kind, request.payload.size()))
throw std::invalid_argument("invalid request payload");
std::vector<std::uint8_t> output(16 + request.payload.size());
auto store = [&](std::size_t offset, std::uint32_t value, unsigned width) {
for (unsigned i = 0; i < width; ++i)
output[offset + i] = static_cast<std::uint8_t>(value >> (8 * i));
};
store(0, 0x31544d41, 4); store(4, 1, 2);
store(6, request.kind, 2);
store(8, static_cast<std::uint32_t>(request.payload.size()), 4);
store(12, request.id, 4);
for (std::size_t i = 0; i < request.payload.size(); ++i)
output[16 + i] = request.payload[i];
return output;
}
הפונקציות load16 וload32 הן helpers עם precondition של טווח תקין. הparser מקיים אותו לפני שהוא קורא להן. אם נחשוף אותן כAPI כללי, נצטרך לבדוק את הטווח שם או להגדיר את התנאי בממשק. כאן נוח לראות שכל גישה לheader מגיעה אחרי בדיקת 16 הבתים.
בדיקת האורך משווה לbytes.size() - 16 אחרי שכבר בדקנו שהגודל אינו קטן מ16. כך איננו מחשבים 16 + size מתוך שדה לא מהימן שעלול לגלוש. גם לפני הקצאת output אנחנו יודעים שהpayload מתאים למגבלה קטנה.
נותנים לכלי לחפש קלט¶
נשתמש בlibFuzzer של LLVM יחד עם AddressSanitizer. נתקין במעבדה גרסת LLVM שכוללת את runtimes הנדרשים לWindows ואת סביבת הlink המתאימה. הפקודות הבאות הן לclang++ באותה סביבה, ולא לdriver של MSVC.
הקובץ fuzz_wire.cpp מחבר קלט של הכלי לparser. הוא דורש שכל קלט שהתקבל יהיה ניתן לייצוג חוזר זהה, ומאפשר לsanitizer לגלות גישות לא תקינות. אין בו main משום שהfuzzer מספק את מנוע ההרצה.
#include "wire.h"
#include <algorithm>
#include <cstdlib>
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
const std::span<const std::uint8_t> bytes(data, size);
if (auto request = parse(bytes)) {
const auto encoded = serialize(*request);
if (!std::equal(encoded.begin(), encoded.end(), bytes.begin(), bytes.end()))
std::abort();
}
return 0;
}
clang++ -std=c++20 -O1 -g -fsanitize=fuzzer,address fuzz_wire.cpp -o fuzz_wire.exe
fuzz_wire.exe corpus -max_total_time=30 -max_len=8192 -timeout=2 -rss_limit_mb=512
ניצור מראש תיקיית corpus ונכניס אליה דוגמה חוקית. בPowerShell אפשר לכתוב seed קבוע בלי תלות בserializer שאותו אנחנו בודקים.
New-Item -ItemType Directory -Path corpus -Force | Out-Null
[byte[]]$seed = 0x41,0x4d,0x54,0x31, 1,0, 1,0, 4,0,0,0, 7,0,0,0, 100,0,0,0
[System.IO.File]::WriteAllBytes((Join-Path $PWD 'corpus/number.bin'), $seed)
הseed מייצג סוג ראשון, מזהה 7 וpayload של ארבעה בתים. הוא נכתב ידנית כדי שהparser והserializer לא יסכימו בטעות על אותה שגיאה ויעברו רק בדיקת round trip. נוסיף גם מקרי גבול ידניים, כגון אורך מוצהר גדול ואפס בגרסה.
הכלי שומר קלטים שמרחיבים coverage וקלט שמוביל לכשל. אחרי כשל נצמצם אותו ונוסיף regression test שמבטא את החוזה שנשבר. הרצה של 30 שניות ללא ממצא אינה הוכחה שאין באג. נבדוק מה כוסה ומה הharness בכלל מאפשר להגיע אליו. את דרך העבודה אפשר לקרוא בתיעוד libFuzzer ובתיעוד AddressSanitizer.
כשל משאבים אינו byte בקלט¶
הfuzzer של הparser אינו בודק מה קורה כשכתיבה לדיסק נכשלת אחרי חלק מהנתונים. לכך נבנה adapter של I/O שניתן להגדיר שייכשל בקריאה מסוימת או אחרי מספר בתים. נריץ את אותו תרחיש עבור כל נקודת כשל סבירה ונבדוק cleanup והתאוששות.
אם פעולה כבר שינתה state לפני שהחזרת שגיאה, נבדוק שהcaller יודע מה השתנה. פונקציה שמחזירה false אינה מבטיחה שהעולם נשאר כפי שהיה. בפרויקט השירות נבדוק במיוחד את הגבול בין שימור תוצאה, פרסום commit ושליחת reply.
גם כשל הקצאה צריך מסלול מוגדר. אם תור מוגבל דוחה עבודה, אסור לעקוף את ההגבלה באמצעות יצירת thread נוסף לכל בקשה שנדחתה. אם רישום event נכשל, אסור להשאיר עבודה ללא owner רק משום שמסלול האבחון עצמו לא הצליח.
ריצה ארוכה עם חשבון¶
בsoak test נספור עבודות שהתקבלו, הסתיימו, נכשלו, בוטלו ונשארו פעילות. נגדיר invariant שמחבר את המספרים. נמדוד peak של תור, bytes של buffers ומספר פעולות שעדיין מחזיקות משאבים.
מספר handles אינו חייב לחזור בדיוק לאותו ערך אחרי כל בקשה אם runtime מחזיק cache מוגבל. לכן נמדוד גם את הסיבה לחיים ואת ההתייצבות לאחר עומס, ולא נסמן כל cache כדליפה. לעומת זאת, גידול עקבי לפי מספר בקשות שהסתיימו דורש הסבר גם אם המערכת עוד לא נגמרה מזיכרון.
נשלב סגירת לקוחות, ביטול ושדרוג תצורה בזמן העומס. הבדיקה אינה רק "השארנו את התוכנית לילה"; היא workload מוגדר עם תנאי עצירה וחשבון משאבים שניתן לבדוק.
חבילת אבחון שאפשר להשתמש בה¶
לכל release נשמור את זהות הקוד, גרסאות התלויות, flags והsymbols המתאימים. חבילת repro תכיל קלט מצומצם, פקודת הרצה, גרסת מערכת ושלבים מדויקים. לא נשלח סתם את כל תיקיית המשתמש כדי להגדיל את הסיכוי שמשהו יעזור.
הדוגמה הבאה היא מבנה אפשרי של metadata לחבילה. הערכים הם שדות שהpipeline ממלא מתוך הbuild והבדיקה בפועל, ולא מספרים שנמציא בדוח.
{
"build_id": "value-from-build-pipeline",
"binary_sha256": "hash-of-the-exact-binary",
"symbol_identity": "identity-from-the-matching-debug-artifact",
"scenario": "cancel-after-commit-before-reply",
"input_file": "minimal-repro.bin",
"trace_file": "repro.etl",
"collection_scope": "lab-process-with-synthetic-data"
}
המידע שנאסף צריך לענות על שאלת החקירה. התוכן של payloadים, tokens ומסמכים אינם שדות log רגילים. אם dump מלא נדרש, ננהל אותו כartifact שמכיל זיכרון של התהליך ונגדיר גישה ושמירה בהתאם. במעבדה נשתמש בנתונים סינתטיים ונבדוק שהדוח עדיין מאפשר למצוא את הכשל בלי תלות בסוד של המשתמש.