ניפוי באגים של דליפות זיכרון ב-WebAssembly באמצעות Emscripten

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

Squoosh.app היא PWA שממחישה עד כמה ההגדרות ורכיבי הקודק השונים של התמונות יכולים לשפר את הגודל של קובץ התמונה בלי להשפיע באופן משמעותי על האיכות. עם זאת, זוהי גם הדגמה טכנית שממחישה איך אפשר לקחת ספריות שנכתבו ב-C++ או Rust ולהביא אותן לאינטרנט.

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

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

קו ביטול נעילה חשוד

לאחרונה, כשהתחלתי לעבוד על Squoosh, לא הצלחתי להבחין בדפוס מעניין ברכיבי wrapper של מקודדים של C++. לדוגמה, נסתכל על wrapper של ImageQuant (הקטן ועכשיו הוא מציג רק חלקים של יצירת אובייקטים ומיקום עסקאות):

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript (טוב, TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

נתקלת בבעיה? רמז: אפשר להשתמש בו use-after-free, אבל ב-JavaScript!

ב-Emscripten, typed_memory_view מחזיר Uint8Array של JavaScript שמגובה על ידי מאגר הנתונים הזמני WebAssembly (Wasm), כאשר byteOffset ו-byteLength מוגדרים למצביע ולאורך הנתונים. הנקודה העיקרית היא שזוהי תצוגה של TypedArray למאגר נתונים זמני של WebAssembly, ולא לעותק בבעלות JavaScript של הנתונים.

כשאנחנו קוראים ל-free_result מ-JavaScript, הוא מפעיל פונקציית C רגילה free כדי לסמן את הזיכרון הזה כזמין להקצאות עתידיות. כלומר, אפשר להחליף את הנתונים שהצפייה Uint8Array שלנו מפנה אליהם בנתונים שרירותיים על ידי כל קריאה עתידית ל-Wasm.

לחלופין, הטמעה מסוימת של free עלולה אפילו לאפס את הזיכרון שהתפנה מיד. השדה free ש-Emscripten משתמש בו לא עושה את זה, אבל אנחנו מסתמכים על פרט ההטמעה שאי אפשר להבטיח.

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

אני רוצה להשתמש במסוף כלי הפיתוח (או Node.js) כדי להדגים את ההתנהגות הזו:

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

לסיום, גם אם לא נבצע קריאה נוספת ל-Wasm באופן מפורש בין free_result ל-new Uint8ClampedArray, בשלב מסוים יכול להיות שנוסיף תמיכה בריבוי שרשורים ל-Codec שלנו. במקרה הזה יכול להיות ש-thread אחר יחליף את הנתונים ממש לפני שנצליח לשכפל אותם.

מתבצע חיפוש של באגים בזיכרון

למקרה שהחלטתי להמשיך בבדיקה, החלטתי לבדוק אם קיימות בעיות בפועל בקוד הזה. זו הזדמנות מושלמת לנסות את התמיכה החדשה(ish) לחומרי חיטוי של Emscripten שנוספה בשנה שעברה והוצגה בהרצאה של WebAssembly בכנס Chrome Dev Summit:

במקרה הזה, אנחנו מתעניינים בכלי AddressSanitizer, שיכול לזהות בעיות שונות שקשורות להצבעה ולזיכרון. כדי להשתמש בו, אנחנו צריכים להדר מחדש את הקודק שלנו באמצעות -fsanitize=address:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

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

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

נחשוף את כלי העזר השני באמצעות Embind כדי שנוכל לקרוא לו מ-JavaScript בכל עת:

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

צריך להפעיל אותו מהצד של ה-JavaScript כשנסיים עם התמונה. פעולה זו מהצד של JavaScript ולא מה-C++ , עוזרת לוודא שכל ההיקפים הוצאו משימוש ושכל האובייקטים הזמניים של C++ התפנו אחרי שאנחנו מבצעים את הבדיקות האלה:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

כך מתקבל במסוף דוח כמו בדוגמה הבאה:

צילום מסך של הודעה

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

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

זה נראה הרבה יותר טוב:

צילום מסך של הודעה שבה כתוב &#39;דליפה ישירה של 12 בייטים&#39; שמגיעה מהפונקציה GeneralBindingType RawImage ::toWireType

חלקים מסוימים ממעקב המחסניות עדיין נראים מעורפלים כשהם מפנים אל גורמים פנימיים של Emscripten, אבל אפשר לומר שהדליפה מגיעה מהמרה של RawImage ל'סוג העברה' (לערך JavaScript) על ידי Embind. אכן, כשבוחנים את הקוד, אפשר לראות שאנחנו מחזירים RawImage מופעים של C++ ל-JavaScript, אבל אף פעם לא משחררים אותם באף אחד מהצדדים.

חשוב לזכור שאין כרגע שילוב של איסוף אשפה בין JavaScript ל-WebAssembly, אבל אנחנו עובדים על תהליך פיתוח. במקום זאת, כשמסיימים להשתמש באובייקט צריך לשחרר באופן ידני כל זיכרון וקריאה מהצד של ה-JavaScript. לגבי Embind באופן ספציפי, המסמכים הרשמיים מציעים לקרוא ל-method .delete() במחלקות C++ שנחשפו:

קוד JavaScript צריך למחוק באופן מפורש כל אובייקט C++ שמטפל שהוא קיבל, אחרת הערימה של Emscripten תגדל ללא הגבלת זמן.

var x = new Module.MyClass;
x.method();
x.delete();

למעשה, כשאנחנו עושים זאת ב-JavaScript למחלקה שלנו:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

הדליפה תיעלם כמצופה.

בעיות נוספות שקשורות לחיטוי

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

צילום מסך של הודעה

במקרה הזה, זו לא דליפה, אלא אנחנו כותבים לזיכרון מחוץ לגבולות שהוקצו 😱

במבט קדימה בקוד של MozJPEG, גילינו שהבעיה כאן היא ש-jpeg_mem_dest - הפונקציה שבה אנחנו משתמשים להקצאת יעד זיכרון עבור JPEG - משתמשת שוב בערכים הקיימים של outbuffer ו-outsize כשהם לא אפס:

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

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

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

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

בעיות במצב משותף

...או שאנחנו עדיין?

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

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

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

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

צילום מסך של הודעה

262,144 בייטים – נראה שכל התמונה לדוגמה הודלפה מ-jpeg_finish_compress.

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

אפשר לתקן את הבעיה על ידי שחרור הנתונים באופן ידני בפונקציה free_result:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

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

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

בניית wrapper בטוח

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

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

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

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

לשם כך, אנחנו מסדרים מחדש את ה-wrapper של C++ כדי לוודא שכל קריאה לפונקציה מנהלת את הנתונים שלה באמצעות משתנים מקומיים. לאחר מכן נוכל לשנות את החתימה של הפונקציה free_result כדי לאשר את המצביע בחזרה:

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // …
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

אבל מכיוון שאנחנו כבר משתמשים ב-Embind ב-Emscripten לאינטראקציה עם JavaScript, ייתכן שגם נהפוך את ה-API לבטוח עוד יותר כי נסתיר לחלוטין את פרטי ניהול הזיכרון של C++!

לשם כך, נעביר את החלק new Uint8ClampedArray(…) מ-JavaScript לצד C++ באמצעות Embind. אחר כך נוכל להשתמש בו כדי לשכפל את הנתונים בזיכרון ה-JavaScript עוד לפני החזרה מהפונקציה:

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/* … */) {
val quantize(/* … */) {
  // …
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

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

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

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

זה גם אומר שאנחנו לא צריכים יותר קישור free_result מותאם אישית בצד C++:

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

לסיכום, קוד ה-wrapper שלנו הפך להיות נקי ובטוח יותר בו-זמנית.

אחרי זה ביצעתי שיפורים קלים נוספים בקוד של wrapper של ImageQuant ושכפלתי תיקוני ניהול זיכרון דומים עבור רכיבי קודק אחרים. לפרטים נוספים, ראו את ה-PR שנוצר כאן: תיקוני זיכרון לרכיבי קודק C++.

חטיפות דסקית

אילו לקחים אפשר ללמוד ולשתף מארגון הקוד מחדש (Refactoring) שניתן ליישם בבסיסי קוד אחרים?

  • לא כדאי להשתמש בתצוגות זיכרון שמגובות על ידי WebAssembly – לא משנה איזו שפה היא נוצרה – מעבר להפעלה יחידה. אי אפשר לסמוך על כך שהם ישרודו יותר זמן, ולא תוכלו לאתר באגים כאלה באמצעים רגילים. לכן, אם אתם צריכים לאחסן את הנתונים למועד מאוחר יותר, כדאי להעתיק אותם לצד ה-JavaScript ולאחסן אותם שם.
  • אם אפשר, השתמשו בשפה בטוחה לניהול זיכרון או לפחות ב-wrappers מסוג בטוח, במקום לפעול ישירות על מצביעים גולמיים. הפעולה הזו לא תגן עליכם מבאגים בגבול ה-JavaScript פועל WebAssembly, אבל לפחות היא תצמצם את השטח להצגת באגים שקורים באופן עצמאי על ידי קוד השפה הסטטי.
  • לא משנה באיזו שפה משתמשים, מומלץ להריץ את הקוד באמצעות חומרי חיטוי במהלך הפיתוח – הם יכולים לעזור לכם לזהות לא רק בעיות בקוד השפה הסטטי, אלא גם כמה בעיות בגבולות ה-JavaScript פועל, למשל אם לא מצליחים להפעיל את .delete() או להעביר מזהים לא חוקיים מהצד של JavaScript.
  • במידת האפשר, הימנעו מחשיפת נתונים ואובייקטים לא מנוהלים מ-WebAssembly ל-JavaScript. JavaScript היא שפה שנאספת באשפה, וניהול זיכרון ידני אינו נפוץ בה. מצב כזה נחשב כדליפה מופשטת של מודל הזיכרון של השפה שממנה נוצר ה-WebAssembly, ואפשר להתעלם מניהול שגוי ב-codebase של JavaScript.
  • יכול להיות שזה מובן מאליו, אבל כמו בכל בסיס קוד אחר, כדאי להימנע משמירת מצב שאפשר לשנות במשתנים גלובליים. אתם לא רוצים לנפות באגים באמצעות השימוש החוזר בהפעלות שונות או אפילו בשרשורים, ולכן עדיף לשמור על נייטיב עצמאי ככל האפשר.