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

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

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

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

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

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

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

חטיפות דסקית

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

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