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

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

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

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

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

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

לאחרונה, כשהתחלתי לעבוד על Squoosh, שמתי לב לדפוס מעניין ב-wrappers של קודיקים ב-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
  );
}

זיהיתם בעיה? רמז: זהו שימוש לאחר שחרור, אבל ב-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 הקיים, וכתוצאה מכך גם את כל התצוגות שנתמכות בו.

אשתמש במסוף DevTools (או 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, בשלב כלשהו ייתכן שנוסיף תמיכה בריבוי שרשורים לקודקים שלנו. במקרה כזה, יכול להיות שמדובר בשרשור שונה לחלוטין שמחליף את הנתונים ממש לפני שנצליח לשכפל אותם.

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

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

במקרה הזה, אנחנו מתעניינים ב-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 מתאימה יותר לתרחישי שימוש בספרייה כמו שלנו, כשרוצים להדפיס זליגות במסוף אבל להשאיר את האפליקציה פועלת בכל מקרה.

נחשוף את ה-helper השני באמצעות 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
  );
}

הפקודה הזו תיצור דוח כמו זה שמופיע במסוף:

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

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

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; שמגיעה מפונקציית גנריBindingType RawImage ::toWireType

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

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

קוד JavaScript חייב למחוק באופן מפורש את כל ה-handles של אובייקטים ב-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 בטוח

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

קודם כול, נתקן את הבעיה של שימוש לאחר שחרור (use-after-free) מתחילת הפוסט. לשם כך, צריך להעתיק את הנתונים מהתצוגה שנתמכת על ידי 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;
}

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

כדי לעשות זאת, אנחנו מבצעים ריפרקטור של מעטפת ה-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, וגם מסיר את העטיפה של 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());
}

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

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

חטיפות דסקית

מה אנחנו יכולים ללמוד מהפירוק הזה ולשתף אותו עם אחרים, כדי שאפשר יהיה ליישם אותו בקוד בסיס אחר?

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