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

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

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

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

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

דפוס חשוד

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

נחשוף את ה-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
  );
}

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

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

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

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;Direct leak of 12 bytes&#39; שמגיעה מפונקציה GenericBindingType 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, שאליו לכלי הסינון אין גישה. הבעיות האלה יופיעו רק בסביבת הייצור או לאחר שינויים בקוד בעתיד שנראים לא קשורים.

יצירת עטיפה בטוחה

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

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