diff --git a/lib/native-types.ts b/lib/native-types.ts index b3ae98b6..bf08ecd4 100644 --- a/lib/native-types.ts +++ b/lib/native-types.ts @@ -526,6 +526,7 @@ export interface NativeModule { videoDecoders: number; audioEncoders: number; audioDecoders: number; + imageDecoders: number; queue: number; process: number; frames: number; diff --git a/src/addon.cc b/src/addon.cc index a775afd4..d55ec2b7 100644 --- a/src/addon.cc +++ b/src/addon.cc @@ -71,6 +71,8 @@ Napi::Value GetCountersJS(const Napi::CallbackInfo& info) { static_cast(webcodecs::counterAudioEncoders.load())); counters.Set("audioDecoders", static_cast(webcodecs::counterAudioDecoders.load())); + counters.Set("imageDecoders", + static_cast(webcodecs::counterImageDecoders.load())); // Legacy counters (for backwards compatibility) counters.Set("queue", webcodecs::counterQueue.load()); diff --git a/src/common.cc b/src/common.cc index 67017088..d6ecda40 100644 --- a/src/common.cc +++ b/src/common.cc @@ -53,6 +53,10 @@ static std::atomic& GetCounterAudioDecoders() { static auto* counter = new std::atomic(0); return *counter; } +static std::atomic& GetCounterImageDecoders() { + static auto* counter = new std::atomic(0); + return *counter; +} // Legacy counters (maintained for backwards compatibility) static std::atomic& GetCounterQueue() { @@ -75,6 +79,7 @@ std::atomic& counterVideoEncoders = GetCounterVideoEncoders(); std::atomic& counterVideoDecoders = GetCounterVideoDecoders(); std::atomic& counterAudioEncoders = GetCounterAudioEncoders(); std::atomic& counterAudioDecoders = GetCounterAudioDecoders(); +std::atomic& counterImageDecoders = GetCounterImageDecoders(); std::atomic& counterQueue = GetCounterQueue(); std::atomic& counterProcess = GetCounterProcess(); diff --git a/src/common.h b/src/common.h index a7c2fbce..24e0fc80 100644 --- a/src/common.h +++ b/src/common.h @@ -123,6 +123,7 @@ extern std::atomic& counterVideoEncoders; extern std::atomic& counterVideoDecoders; extern std::atomic& counterAudioEncoders; extern std::atomic& counterAudioDecoders; +extern std::atomic& counterImageDecoders; // Legacy counters (maintained for backwards compatibility) extern std::atomic& counterQueue; diff --git a/src/ffmpeg_raii.h b/src/ffmpeg_raii.h index 75beca6a..fc16b83a 100644 --- a/src/ffmpeg_raii.h +++ b/src/ffmpeg_raii.h @@ -115,6 +115,40 @@ struct AVFilterInOutDeleter { } }; +// Forward declare MemoryBufferContext from image_decoder.cc +struct MemoryBufferContext; + +// MemoryBufferContext deleter (custom delete) +// Used by ImageDecoder for in-memory buffer I/O context +struct MemoryBufferContextDeleter { + void operator()(MemoryBufferContext* ctx) const noexcept { delete ctx; } +}; + +// AVIOContext deleter (handles avio_context_free semantics) +// NOTE: Also frees the internal buffer allocated with av_malloc +struct AVIOContextDeleter { + void operator()(AVIOContext* ctx) const noexcept { + if (ctx) { + // Free the buffer allocated with av_malloc before freeing context + if (ctx->buffer) { + av_freep(&ctx->buffer); + } + avio_context_free(&ctx); + } + } +}; + +// AVFormatContext deleter for image decoding (uses alloc + close_input) +// Also cleans up associated AVIO context stored in ctx->pb +struct ImageFormatContextDeleter { + void operator()(AVFormatContext* ctx) const noexcept { + if (ctx) { + // avformat_close_input handles both the context and its streams + avformat_close_input(&ctx); + } + } +}; + // Type aliases for convenient usage using AVFramePtr = std::unique_ptr; using AVPacketPtr = std::unique_ptr; @@ -128,6 +162,11 @@ using AVFormatContextOutputPtr = std::unique_ptr; using AVFilterGraphPtr = std::unique_ptr; using AVFilterInOutPtr = std::unique_ptr; +using MemoryBufferContextPtr = + std::unique_ptr; +using AVIOContextPtr = std::unique_ptr; +using ImageFormatContextPtr = + std::unique_ptr; // Factory functions for cleaner allocation inline AVFramePtr make_frame() { return AVFramePtr(av_frame_alloc()); } diff --git a/src/image_decoder.cc b/src/image_decoder.cc index be91774d..c5ec6f12 100644 --- a/src/image_decoder.cc +++ b/src/image_decoder.cc @@ -33,14 +33,18 @@ static void PremultiplyAlpha(uint8_t* rgba_data, int width, int height) { } // Custom read callback for AVIOContext to read from memory buffer +// NOTE: Defined in ffmpeg namespace to match RAII wrapper in ffmpeg_raii.h +namespace ffmpeg { struct MemoryBufferContext { const uint8_t* data; size_t size; size_t position; }; +} // namespace ffmpeg static int ReadPacket(void* opaque, uint8_t* buf, int buf_size) { - MemoryBufferContext* ctx = static_cast(opaque); + ffmpeg::MemoryBufferContext* ctx = + static_cast(opaque); int64_t remaining = static_cast(ctx->size - ctx->position); if (remaining <= 0) { return AVERROR_EOF; @@ -52,7 +56,8 @@ static int ReadPacket(void* opaque, uint8_t* buf, int buf_size) { } static int64_t SeekPacket(void* opaque, int64_t offset, int whence) { - MemoryBufferContext* ctx = static_cast(opaque); + ffmpeg::MemoryBufferContext* ctx = + static_cast(opaque); int64_t new_pos = 0; switch (whence) { @@ -108,9 +113,9 @@ ImageDecoder::ImageDecoder(const Napi::CallbackInfo& info) sws_context_(), frame_(), packet_(), - format_context_(nullptr), - avio_context_(nullptr), - mem_ctx_(nullptr), + format_context_(), + avio_context_(), + mem_ctx_(), video_stream_index_(-1), decoded_width_(0), decoded_height_(0), @@ -120,6 +125,8 @@ ImageDecoder::ImageDecoder(const Napi::CallbackInfo& info) complete_(false), closed_(false), premultiply_alpha_("default") { + // Increment instance counter for leak detection + webcodecs::counterImageDecoders.fetch_add(1); Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsObject()) { @@ -230,29 +237,31 @@ ImageDecoder::ImageDecoder(const Napi::CallbackInfo& info) } } -ImageDecoder::~ImageDecoder() { Cleanup(); } +ImageDecoder::~ImageDecoder() { + // Decrement instance counter for leak detection + webcodecs::counterImageDecoders.fetch_sub(1); + Cleanup(); +} void ImageDecoder::Cleanup() { - // RAII wrappers handle deallocation automatically via reset() + // Reset RAII members (automatic cleanup) + codec_context_.reset(); sws_context_.reset(); frame_.reset(); packet_.reset(); - codec_context_.reset(); - if (format_context_) { - avformat_close_input(&format_context_); - format_context_ = nullptr; - } - // Free MemoryBufferContext BEFORE avio_context_free (it's stored in opaque) - if (mem_ctx_) { - delete mem_ctx_; - mem_ctx_ = nullptr; - } - if (avio_context_) { - // The buffer is freed by avio_context_free - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - } + + // Reset animated image RAII members + // Order matters: format_context_ first (may reference avio_context_->buffer) + // then avio_context_ (frees buffer too via AVIOContextDeleter) + // then mem_ctx_ (no longer needed) + format_context_.reset(); // Calls ImageFormatContextDeleter + avio_context_.reset(); // Calls AVIOContextDeleter (frees buffer too) + mem_ctx_.reset(); // Calls MemoryBufferContextDeleter + + video_stream_index_ = -1; + + // Clear decoded frame data + decoded_data_.clear(); decoded_frames_.clear(); } @@ -320,41 +329,39 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { return false; } - // Allocate memory buffer context for custom I/O - mem_ctx_ = new MemoryBufferContext(); - mem_ctx_->data = data_.data(); - mem_ctx_->size = data_.size(); - mem_ctx_->position = 0; + // Allocate memory buffer context for custom I/O (RAII managed) + mem_ctx_.reset(new ffmpeg::MemoryBufferContext{ + .data = data_.data(), + .size = data_.size(), + .position = 0, + }); - // Allocate AVIO buffer + // Allocate AVIO buffer (will be owned by AVIOContext) uint8_t* avio_buffer = static_cast(av_malloc(kAVIOBufferSize)); if (!avio_buffer) { - delete mem_ctx_; - mem_ctx_ = nullptr; + mem_ctx_.reset(); // RAII cleanup return false; } - // Create custom AVIO context - avio_context_ = avio_alloc_context(avio_buffer, kAVIOBufferSize, 0, mem_ctx_, - ReadPacket, nullptr, SeekPacket); - if (!avio_context_) { - av_free(avio_buffer); - delete mem_ctx_; - mem_ctx_ = nullptr; + // Create custom AVIO context (takes ownership of avio_buffer) + AVIOContext* raw_avio = avio_alloc_context( + avio_buffer, kAVIOBufferSize, 0, mem_ctx_.get(), + ReadPacket, nullptr, SeekPacket); + if (!raw_avio) { + av_free(avio_buffer); // avio_alloc_context failed, free buffer manually + mem_ctx_.reset(); return false; } + avio_context_.reset(raw_avio); // Transfer ownership to RAII wrapper - // Allocate format context - format_context_ = avformat_alloc_context(); + // Allocate format context (RAII managed) + format_context_.reset(avformat_alloc_context()); if (!format_context_) { - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - delete mem_ctx_; - mem_ctx_ = nullptr; + // RAII will clean up avio_context_ and mem_ctx_ automatically return false; } - format_context_->pb = avio_context_; + format_context_->pb = avio_context_.get(); // Use raw pointer for FFmpeg API format_context_->flags |= AVFMT_FLAG_CUSTOM_IO; // Determine format based on MIME type @@ -365,30 +372,20 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { input_format = av_find_input_format("webp"); } - // Open input - int ret = - avformat_open_input(&format_context_, nullptr, input_format, nullptr); + // Open input (avformat_open_input takes ownership on success, frees on failure) + AVFormatContext* raw_fmt = format_context_.release(); // Release ownership + int ret = avformat_open_input(&raw_fmt, nullptr, input_format, nullptr); if (ret < 0) { - // format_context_ is freed by avformat_open_input on failure - format_context_ = nullptr; - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; + // avformat_open_input freed raw_fmt on failure, set to nullptr + // RAII handles cleanup of avio_context_ and mem_ctx_ return false; } + format_context_.reset(raw_fmt); // Take ownership back on success // Find stream info - ret = avformat_find_stream_info(format_context_, nullptr); + ret = avformat_find_stream_info(format_context_.get(), nullptr); if (ret < 0) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } // Find video stream @@ -402,13 +399,7 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { } if (video_stream_index_ < 0) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } AVStream* video_stream = format_context_->streams[video_stream_index_]; @@ -421,63 +412,33 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { // Find decoder for the stream const AVCodec* stream_codec = avcodec_find_decoder(codecpar->codec_id); if (!stream_codec) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } // Allocate new codec context for the stream (RAII managed) ffmpeg::AVCodecContextPtr stream_codec_ctx = ffmpeg::make_codec_context(stream_codec); if (!stream_codec_ctx) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } // Copy codec parameters ret = avcodec_parameters_to_context(stream_codec_ctx.get(), codecpar); if (ret < 0) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } // Open codec ret = avcodec_open2(stream_codec_ctx.get(), stream_codec, nullptr); if (ret < 0) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } // Count frames and decode them (RAII managed) ffmpeg::AVPacketPtr pkt = ffmpeg::make_packet(); ffmpeg::AVFramePtr frm = ffmpeg::make_frame(); if (!pkt || !frm) { - avformat_close_input(&format_context_); - av_freep(&avio_context_->buffer); - avio_context_free(&avio_context_); - avio_context_ = nullptr; - delete mem_ctx_; - mem_ctx_ = nullptr; - return false; + return false; // RAII handles cleanup automatically } frame_count_ = 0; @@ -513,7 +474,7 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { repetition_count_ = std::numeric_limits::infinity(); AVDictionaryEntry* loop_entry = - av_dict_get(format_context_->metadata, "loop", nullptr, 0); + av_dict_get(format_context_->metadata, "loop", nullptr, 0); // -> works on unique_ptr if (loop_entry) { int loop_count = std::atoi(loop_entry->value); if (loop_count == 0) { @@ -525,7 +486,7 @@ bool ImageDecoder::ParseAnimatedImageMetadata() { } // Read all frames - while (av_read_frame(format_context_, pkt.get()) >= 0) { + while (av_read_frame(format_context_.get(), pkt.get()) >= 0) { if (pkt->stream_index == video_stream_index_) { ret = avcodec_send_packet(stream_codec_ctx.get(), pkt.get()); if (ret >= 0) { diff --git a/src/image_decoder.h b/src/image_decoder.h index cfb4a7b3..bf9bf230 100644 --- a/src/image_decoder.h +++ b/src/image_decoder.h @@ -75,10 +75,10 @@ class ImageDecoder : public Napi::ObjectWrap { ffmpeg::AVPacketPtr packet_; // FFmpeg state for animated image parsing. - AVFormatContext* format_context_; // For container parsing - AVIOContext* avio_context_; // Custom I/O for memory buffer - struct MemoryBufferContext* mem_ctx_; // Owned, freed in Cleanup() - int video_stream_index_; // Stream index for video track + ffmpeg::ImageFormatContextPtr format_context_; // For container parsing + ffmpeg::AVIOContextPtr avio_context_; // Custom I/O for memory buffer + ffmpeg::MemoryBufferContextPtr mem_ctx_; // Owned, RAII managed + int video_stream_index_; // Stream index for video track // Decoded frame data (static images). std::vector decoded_data_; diff --git a/test/golden/image-decoder.test.ts b/test/golden/image-decoder.test.ts index 2b477ab8..d6c7c064 100644 --- a/test/golden/image-decoder.test.ts +++ b/test/golden/image-decoder.test.ts @@ -912,4 +912,21 @@ describe('ImageDecoder', () => { decoder.close(); }); }); + + describe('Resource Management', () => { + it('can be constructed multiple times without leaks', async () => { + const iterations = 100; + for (let i = 0; i < iterations; i++) { + const decoder = new ImageDecoder({ + type: 'image/png', + data: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + ]), + }); + decoder.close(); + } + // If there's a leak, this will accumulate memory + // We'll verify with leak checker in Task 3 + }); + }); }); diff --git a/test/helpers/leak-check.ts b/test/helpers/leak-check.ts index 414ea633..8d3c5ae8 100644 --- a/test/helpers/leak-check.ts +++ b/test/helpers/leak-check.ts @@ -30,6 +30,7 @@ export interface CounterSnapshot { videoDecoders: number; audioEncoders: number; audioDecoders: number; + imageDecoders: number; } export function getCounters(): CounterSnapshot { @@ -86,6 +87,13 @@ export function assertNoLeaks( ' instances not released' ); } + if (after.imageDecoders !== before.imageDecoders) { + leaks.push( + 'ImageDecoder leak detected: ' + + (after.imageDecoders - before.imageDecoders) + + ' instances not released' + ); + } if (leaks.length > 0) { throw new Error(prefix + leaks.join('; ')); diff --git a/test/stress/memory-leak.test.ts b/test/stress/memory-leak.test.ts index a988f92f..2a5b5cc5 100644 --- a/test/stress/memory-leak.test.ts +++ b/test/stress/memory-leak.test.ts @@ -310,6 +310,173 @@ describe('Memory Leak Detection', () => { }); }); +describe('ImageDecoder Leak Detection', () => { + let initialCounters: CounterSnapshot; + + before(() => { + initialCounters = getCounters(); + }); + + after(async () => { + await waitForGC(); + const finalCounters = getCounters(); + assertNoLeaks(initialCounters, finalCounters, 'ImageDecoder Leak Detection'); + }); + + it('ImageDecoder: animated GIF decode does not leak', async () => { + const before = getCounters(); + + // Create minimal animated GIF (2 frames, 1x1 pixel each) + const createAnimatedGIF = (): Buffer => { + const header = Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" + 0x01, 0x00, // Width: 1 + 0x01, 0x00, // Height: 1 + 0x80, // Global color table flag, 2 colors + 0x00, // Background color index + 0x00, // Pixel aspect ratio + ]); + const colorTable = Buffer.from([ + 0xff, 0x00, 0x00, // Red + 0x00, 0x00, 0xff, // Blue + ]); + const netscapeExt = Buffer.from([ + 0x21, 0xff, // Application Extension + 0x0b, // Block size + 0x4e, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, // "NETSCAPE" + 0x32, 0x2e, 0x30, // "2.0" + 0x03, // Sub-block size + 0x01, // Sub-block ID + 0x00, 0x00, // Loop count (0 = infinite) + 0x00, // Block terminator + ]); + const frame1 = Buffer.from([ + 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00, // GCE + 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image descriptor + 0x02, 0x02, 0x44, 0x01, 0x00, // LZW data + ]); + const frame2 = Buffer.from([ + 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00, // GCE + 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image descriptor + 0x02, 0x02, 0x44, 0x51, 0x00, // LZW data + ]); + const trailer = Buffer.from([0x3b]); + return Buffer.concat([header, colorTable, netscapeExt, frame1, frame2, trailer]); + }; + + const gifData = createAnimatedGIF(); + const iterations = 50; + + for (let i = 0; i < iterations; i++) { + // Wrap in IIFE to ensure proper scoping for GC + await (async () => { + const { ImageDecoder } = await import('@pproenca/node-webcodecs'); + let decoder: InstanceType | null = new ImageDecoder({ + type: 'image/gif', + data: gifData, + }); + + // Decode frames and close the returned VideoFrame images to prevent leaks + let result0 = await decoder.decode({ frameIndex: 0 }); + result0.image.close(); + result0 = null as unknown as typeof result0; + + let result1 = await decoder.decode({ frameIndex: 1 }); + result1.image.close(); + result1 = null as unknown as typeof result1; + + decoder.close(); + decoder = null; + })(); + } + + // Force multiple GC cycles to ensure all instances are collected + for (let i = 0; i < 5; i++) { + forceGC(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const after = getCounters(); + assertNoLeaks(before, after, 'ImageDecoder'); + }); + + it('ImageDecoder: static PNG decode does not leak', async () => { + const before = getCounters(); + + // Create minimal 1x1 red PNG + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1, 0); // width + ihdr.writeUInt32BE(1, 4); // height + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type (RGB) + + const createChunk = (type: string, data: Buffer): Buffer => { + const typeBuffer = Buffer.from(type, 'ascii'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + // CRC32 + let crc = 0xffffffff; + const table: number[] = []; + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c; + } + const crcData = Buffer.concat([typeBuffer, data]); + for (let i = 0; i < crcData.length; i++) { + crc = table[(crc ^ crcData[i]) & 0xff] ^ (crc >>> 8); + } + const crcBuffer = Buffer.alloc(4); + crcBuffer.writeUInt32BE((crc ^ 0xffffffff) >>> 0); + return Buffer.concat([length, typeBuffer, data, crcBuffer]); + }; + + const { deflateSync } = await import('node:zlib'); + const rawData = Buffer.from([0, 255, 0, 0]); // filter byte + RGB + const compressedData = deflateSync(rawData); + + const pngData = Buffer.concat([ + pngSignature, + createChunk('IHDR', ihdr), + createChunk('IDAT', compressedData), + createChunk('IEND', Buffer.alloc(0)), + ]); + + const iterations = 50; + + for (let i = 0; i < iterations; i++) { + // Wrap in IIFE to ensure proper scoping for GC + await (async () => { + const { ImageDecoder } = await import('@pproenca/node-webcodecs'); + let decoder: InstanceType | null = new ImageDecoder({ + type: 'image/png', + data: pngData, + }); + + // Decode frame and close the returned VideoFrame to prevent leaks + let result = await decoder.decode({ frameIndex: 0 }); + result.image.close(); + result = null as unknown as typeof result; + + decoder.close(); + decoder = null; + })(); + } + + // Force multiple GC cycles to ensure all instances are collected + for (let i = 0; i < 5; i++) { + forceGC(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const after = getCounters(); + assertNoLeaks(before, after, 'ImageDecoder PNG'); + }); +}); + describe('Stress Tests', () => { let initialCounters: CounterSnapshot; diff --git a/test/unit/ffmpeg_raii.test.cc b/test/unit/ffmpeg_raii.test.cc new file mode 100644 index 00000000..e67567df --- /dev/null +++ b/test/unit/ffmpeg_raii.test.cc @@ -0,0 +1,50 @@ +// Copyright 2024 The node-webcodecs Authors +// SPDX-License-Identifier: MIT +// +// Unit tests for RAII wrappers in ffmpeg_raii.h +// This test verifies MemoryBufferContextPtr behavior without FFmpeg deps + +#include +#include +#include + +namespace ffmpeg { + +// Define MemoryBufferContext for testing (tracks deletion) +// In production, this will be defined in image_decoder.cc +struct MemoryBufferContext { + int value; + bool* deleted_flag; + ~MemoryBufferContext() { + if (deleted_flag) *deleted_flag = true; + } +}; + +// MemoryBufferContext deleter (custom delete) +// Mirrors the deleter that will be added to ffmpeg_raii.h +struct MemoryBufferContextDeleter { + void operator()(MemoryBufferContext* ctx) const noexcept { delete ctx; } +}; + +using MemoryBufferContextPtr = + std::unique_ptr; + +} // namespace ffmpeg + +void test_memory_buffer_context_deleter() { + bool deleted = false; + { + ffmpeg::MemoryBufferContext* ctx = + new ffmpeg::MemoryBufferContext{42, &deleted}; + ffmpeg::MemoryBufferContextPtr ptr(ctx); + assert(ptr->value == 42); + } // ptr goes out of scope + assert(deleted == true); // Deleter called delete + printf("PASS: test_memory_buffer_context_deleter\n"); +} + +int main() { + test_memory_buffer_context_deleter(); + printf("All tests passed!\n"); + return 0; +}