diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index a3458ae0ef75c..94b04f5c1abf6 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -685,14 +685,11 @@ class SkAnimatedImage { external SkImage getCurrentFrame(); external int width(); external int height(); - external Uint8List readPixels(SkImageInfo imageInfo, int srcX, int srcY); - external SkData encodeToData(); /// Deletes the C++ object. /// /// This object is no longer usable after calling this method. external void delete(); - external bool isAliasOf(SkAnimatedImage other); external bool isDeleted(); } @@ -1820,6 +1817,7 @@ class SkData { external int size(); external bool isEmpty(); external Uint8List bytes(); + external void delete(); } @JS() diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 388af1f4343a3..5c558d5fda50a 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -8,20 +8,17 @@ part of engine; /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia. void skiaInstantiateImageCodec(Uint8List list, Callback callback, [int? width, int? height, int? format, int? rowBytes]) { - final SkAnimatedImage skAnimatedImage = - canvasKit.MakeAnimatedImageFromEncoded(list); - final CkAnimatedImage animatedImage = CkAnimatedImage(skAnimatedImage); - final CkAnimatedImageCodec codec = CkAnimatedImageCodec(animatedImage); + final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list); callback(codec); } /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after /// requesting from URI. Future skiaInstantiateWebImageCodec( - String src, WebOnlyImageCodecChunkCallback? chunkCallback) { + String uri, WebOnlyImageCodecChunkCallback? chunkCallback) { Completer completer = Completer(); //TODO: Switch to using MakeImageFromCanvasImageSource when animated images are supported. - html.HttpRequest.request(src, responseType: "arraybuffer", + html.HttpRequest.request(uri, responseType: "arraybuffer", onProgress: (html.ProgressEvent event) { if (event.lengthComputable) { chunkCallback?.call(event.loaded!, event.total!); @@ -33,10 +30,7 @@ Future skiaInstantiateWebImageCodec( } final Uint8List list = new Uint8List.view((response.response as ByteBuffer)); - final SkAnimatedImage skAnimatedImage = - canvasKit.MakeAnimatedImageFromEncoded(list); - final CkAnimatedImage animatedImage = CkAnimatedImage(skAnimatedImage); - final CkAnimatedImageCodec codec = CkAnimatedImageCodec(animatedImage); + final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list); completer.complete(codec); }, onError: (dynamic error) { completer.completeError(error); @@ -44,123 +38,115 @@ Future skiaInstantiateWebImageCodec( return completer.future; } -/// A wrapper for `SkAnimatedImage`. -class CkAnimatedImage implements ui.Image { - // Use a box because `SkImage` may be deleted either due to this object - // being garbage-collected, or by an explicit call to [delete]. - late final SkiaObjectBox box; +/// The CanvasKit implementation of [ui.Codec]. +/// +/// Wraps `SkAnimatedImage`. +class CkAnimatedImage implements ui.Codec, StackTraceDebugger { + /// Decodes an image from a list of encoded bytes. + CkAnimatedImage.decodeFromBytes(Uint8List bytes) { + if (assertionsEnabled) { + _debugStackTrace = StackTrace.current; + } + final SkAnimatedImage skAnimatedImage = + canvasKit.MakeAnimatedImageFromEncoded(bytes); + box = SkiaObjectBox(this, skAnimatedImage); + } - SkAnimatedImage get _skAnimatedImage => box.skObject; + // Use a box because `CkAnimatedImage` may be deleted either due to this + // object being garbage-collected, or by an explicit call to [dispose]. + late final SkiaObjectBox box; - CkAnimatedImage(SkAnimatedImage skAnimatedImage) { - box = SkiaObjectBox(this, skAnimatedImage); - } + @override + StackTrace get debugStackTrace => _debugStackTrace!; + StackTrace? _debugStackTrace; + + bool _disposed = false; + bool get debugDisposed => _disposed; - CkAnimatedImage.cloneOf(SkiaObjectBox boxToClone) { - box = boxToClone.clone(this); + bool _debugCheckIsNotDisposed() { + assert(!_disposed, 'This image has been disposed.'); + return true; } - bool _disposed = false; @override void dispose() { - box.delete(); + assert( + !_disposed, + 'Cannot dispose a codec that has already been disposed.', + ); _disposed = true; + + // This image is no longer usable. Bump the ref count. + box.unref(this); } @override - bool get debugDisposed { - if (assertionsEnabled) { - return _disposed; - } - throw StateError( - 'Image.debugDisposed is only available when asserts are enabled.'); + int get frameCount { + assert(_debugCheckIsNotDisposed()); + return box.skiaObject.getFrameCount(); } - ui.Image clone() => CkAnimatedImage.cloneOf(box); - @override - bool isCloneOf(ui.Image other) { - return other is CkAnimatedImage && - other._skAnimatedImage.isAliasOf(_skAnimatedImage); + int get repetitionCount { + assert(_debugCheckIsNotDisposed()); + return box.skiaObject.getRepetitionCount(); } @override - List? debugGetOpenHandleStackTraces() => - box.debugGetStackTraces(); - - int get frameCount => _skAnimatedImage.getFrameCount(); - - /// Decodes the next frame and returns the frame duration. - Duration decodeNextFrame() { - final int durationMillis = _skAnimatedImage.decodeNextFrame(); - return Duration(milliseconds: durationMillis); + Future getNextFrame() { + assert(_debugCheckIsNotDisposed()); + final int durationMillis = box.skiaObject.decodeNextFrame(); + final Duration duration = Duration(milliseconds: durationMillis); + final CkImage image = CkImage(box.skiaObject.getCurrentFrame()); + return Future.value(AnimatedImageFrameInfo(duration, image)); } +} - int get repetitionCount => _skAnimatedImage.getRepetitionCount(); - - CkImage get currentFrameAsImage { - return CkImage(_skAnimatedImage.getCurrentFrame()); +/// A [ui.Image] backed by an `SkImage` from Skia. +class CkImage implements ui.Image, StackTraceDebugger { + CkImage(SkImage skImage) { + if (assertionsEnabled) { + _debugStackTrace = StackTrace.current; + } + box = SkiaObjectBox(this, skImage); } - @override - int get width => _skAnimatedImage.width(); - - @override - int get height => _skAnimatedImage.height(); - - @override - Future toByteData( - {ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) { - Uint8List bytes; - - if (format == ui.ImageByteFormat.rawRgba) { - final SkImageInfo imageInfo = SkImageInfo( - alphaType: canvasKit.AlphaType.Premul, - colorType: canvasKit.ColorType.RGBA_8888, - colorSpace: SkColorSpaceSRGB, - width: width, - height: height, - ); - bytes = _skAnimatedImage.readPixels(imageInfo, 0, 0); - } else { - // Defaults to PNG 100%. - final SkData skData = _skAnimatedImage.encodeToData(); - // Make a copy that we can return. - bytes = Uint8List.fromList(canvasKit.getDataBytes(skData)); + CkImage.cloneOf(this.box) { + if (assertionsEnabled) { + _debugStackTrace = StackTrace.current; } - - final ByteData data = bytes.buffer.asByteData(0, bytes.length); - return Future.value(data); + box.ref(this); } @override - String toString() => '[$width\u00D7$height]'; -} + StackTrace get debugStackTrace => _debugStackTrace!; + StackTrace? _debugStackTrace; -/// A [ui.Image] backed by an `SkImage` from Skia. -class CkImage implements ui.Image { // Use a box because `SkImage` may be deleted either due to this object // being garbage-collected, or by an explicit call to [delete]. - late final SkiaObjectBox box; + late final SkiaObjectBox box; - SkImage get skImage => box.skObject; + /// The underlying Skia image object. + /// + /// Do not store the returned value. It is memory-managed by [SkiaObjectBox]. + /// Storing it may result in use-after-free bugs. + SkImage get skImage => box.skiaObject; - CkImage(SkImage skImage) { - box = SkiaObjectBox(this, skImage); - } + bool _disposed = false; - CkImage.cloneOf(SkiaObjectBox boxToClone) { - box = boxToClone.clone(this); + bool _debugCheckIsNotDisposed() { + assert(!_disposed, 'This image has been disposed.'); + return true; } - bool _disposed = false; @override void dispose() { - box.delete(); - assert(() { - _disposed = true; - return true; - }()); + assert( + !_disposed, + 'Cannot dispose an image that has already been disposed.', + ); + _disposed = true; + box.unref(this); } @override @@ -173,10 +159,14 @@ class CkImage implements ui.Image { } @override - ui.Image clone() => CkImage.cloneOf(box); + ui.Image clone() { + assert(_debugCheckIsNotDisposed()); + return CkImage.cloneOf(box); + } @override bool isCloneOf(ui.Image other) { + assert(_debugCheckIsNotDisposed()); return other is CkImage && other.skImage.isAliasOf(skImage); } @@ -185,14 +175,21 @@ class CkImage implements ui.Image { box.debugGetStackTraces(); @override - int get width => skImage.width(); + int get width { + assert(_debugCheckIsNotDisposed()); + return skImage.width(); + } @override - int get height => skImage.height(); + int get height { + assert(_debugCheckIsNotDisposed()); + return skImage.height(); + } @override Future toByteData( {ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) { + assert(_debugCheckIsNotDisposed()); Uint8List bytes; if (format == ui.ImageByteFormat.rawRgba) { @@ -208,6 +205,7 @@ class CkImage implements ui.Image { final SkData skData = skImage.encodeToData(); //defaults to PNG 100% // make a copy that we can return bytes = Uint8List.fromList(canvasKit.getDataBytes(skData)); + skData.delete(); } final ByteData data = bytes.buffer.asByteData(0, bytes.length); @@ -215,31 +213,9 @@ class CkImage implements ui.Image { } @override - String toString() => '[$width\u00D7$height]'; -} - -/// A [Codec] that wraps an `SkAnimatedImage`. -class CkAnimatedImageCodec implements ui.Codec { - CkAnimatedImage animatedImage; - - CkAnimatedImageCodec(this.animatedImage); - - @override - void dispose() { - animatedImage.dispose(); - } - - @override - int get frameCount => animatedImage.frameCount; - - @override - int get repetitionCount => animatedImage.repetitionCount; - - @override - Future getNextFrame() { - final Duration duration = animatedImage.decodeNextFrame(); - final CkImage image = animatedImage.currentFrameAsImage; - return Future.value(AnimatedImageFrameInfo(duration, image)); + String toString() { + assert(_debugCheckIsNotDisposed()); + return '[$width\u00D7$height]'; } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart index 3ce432d20592e..17ef95318ba13 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart @@ -252,6 +252,15 @@ abstract class OneShotSkiaObject extends SkiaObject { } } +/// Interface that classes wrapping [SkiaObjectBox] must implement. +/// +/// Used to collect stack traces in debug mode. +abstract class StackTraceDebugger { + /// The stack trace pointing to code location that created or upreffed a + /// [SkiaObjectBox]. + StackTrace get debugStackTrace; +} + /// Uses reference counting to manage the lifecycle of a Skia object owned by a /// wrapper object. /// @@ -263,37 +272,47 @@ abstract class OneShotSkiaObject extends SkiaObject { /// /// The [delete] method may be called any number of times. The box /// will only delete the object once. -class SkiaObjectBox { - SkiaObjectBox(Object wrapper, T skObject) - : this._(wrapper, skObject, skObject as SkDeletable, {}); - - SkiaObjectBox._(Object wrapper, this.skObject, this._skDeletable, this._refs) { +class SkiaObjectBox { + SkiaObjectBox(R debugReferrer, this.skiaObject) : _skDeletable = skiaObject as SkDeletable { if (assertionsEnabled) { - _debugStackTrace = StackTrace.current; + debugReferrers.add(debugReferrer); } - _refs.add(this); if (browserSupportsFinalizationRegistry) { - boxRegistry.register(wrapper, this); + boxRegistry.register(this, _skDeletable); } + assert(refCount == debugReferrers.length); } - /// Reference handles to the same underlying [skObject]. - final Set _refs; + /// The number of objects sharing references to this box. + /// + /// When this count reaches zero, the underlying [skiaObject] is scheduled + /// for deletion. + int get refCount => _refCount; + int _refCount = 1; + + /// When assertions are enabled, stores all objects that share this box. + /// + /// The length of this list is always identical to [refCount]. + /// + /// This list can be used for debugging ref counting issues. + final Set debugReferrers = {}; - late final StackTrace? _debugStackTrace; /// If asserts are enabled, the [StackTrace]s representing when a reference /// was created. List? debugGetStackTraces() { if (assertionsEnabled) { - return _refs - .map((SkiaObjectBox box) => box._debugStackTrace!) + return debugReferrers + .map((R referrer) => referrer.debugStackTrace) .toList(); } return null; } /// The Skia object whose lifecycle is being managed. - final T skObject; + /// + /// Do not store this value outside this box. It is memory-managed by + /// [SkiaObjectBox]. Storing it may result in use-after-free bugs. + final T skiaObject; final SkDeletable _skDeletable; /// Whether this object has been deleted. @@ -302,17 +321,23 @@ class SkiaObjectBox { /// Deletes Skia objects when their wrappers are garbage collected. static final SkObjectFinalizationRegistry boxRegistry = - SkObjectFinalizationRegistry(js.allowInterop((SkiaObjectBox box) { - box.delete(); + SkObjectFinalizationRegistry(js.allowInterop((SkDeletable deletable) { + deletable.delete(); })); - /// Returns a clone of this object, which increases its reference count. + /// Increases the reference count of this box because a new object began + /// sharing ownership of the underlying [skiaObject]. /// /// Clones must be [dispose]d when finished. - SkiaObjectBox clone(Object wrapper) { - assert(!_isDeleted, 'Cannot clone from a deleted handle.'); - assert(_refs.isNotEmpty); - return SkiaObjectBox._(wrapper, skObject, _skDeletable, _refs); + void ref(R debugReferrer) { + assert(!_isDeleted, 'Cannot increment ref count on a deleted handle.'); + assert(_refCount > 0); + assert( + debugReferrers.add(debugReferrer), + 'Attempted to increment ref count by the same referrer more than once.', + ); + _refCount += 1; + assert(refCount == debugReferrers.length); } /// Decrements the reference count for the [skObject]. @@ -321,15 +346,16 @@ class SkiaObjectBox { /// /// If this causes the reference count to drop to zero, deletes the /// [skObject]. - void delete() { - if (_isDeleted) { - assert(!_refs.contains(this)); - return; - } - final bool removed = _refs.remove(this); - assert(removed); - _isDeleted = true; - if (_refs.isEmpty) { + void unref(R debugReferrer) { + assert(!_isDeleted, 'Attempted to unref an already deleted Skia object.'); + assert( + debugReferrers.remove(debugReferrer), + 'Attempted to decrement ref count by the same referrer more than once.', + ); + _refCount -= 1; + assert(refCount == debugReferrers.length); + if (_refCount == 0) { + _isDeleted = true; _scheduleSkObjectCollection(_skDeletable); } } @@ -386,7 +412,7 @@ class SkiaObjects { /// /// Since it's expensive to resurrect, we shouldn't just delete it after every /// frame. Instead, add it to a cache and only delete it when the cache fills. - static void manageExpensive(ManagedSkiaObject object) { + static void manageExpensive(SkiaObject object) { registerCleanupCallback(); expensiveCache.add(object); } diff --git a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart index 452cffd495b00..24981933048e5 100644 --- a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart +++ b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart @@ -1187,10 +1187,10 @@ void _canvasTests() { final CkImage image = await picture.toImage(1, 1); final ByteData rawData = await image.toByteData(format: ui.ImageByteFormat.rawRgba); - expect(rawData, isNotNull); + expect(rawData.lengthInBytes, greaterThan(0)); final ByteData pngData = await image.toByteData(format: ui.ImageByteFormat.png); - expect(pngData, isNotNull); + expect(pngData.lengthInBytes, greaterThan(0)); }); } diff --git a/lib/web_ui/test/canvaskit/image_test.dart b/lib/web_ui/test/canvaskit/image_test.dart index 2487efab5f5a5..5141464fb5210 100644 --- a/lib/web_ui/test/canvaskit/image_test.dart +++ b/lib/web_ui/test/canvaskit/image_test.dart @@ -10,6 +10,7 @@ import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; +import '../matchers.dart'; import 'common.dart'; import 'test_data.dart'; @@ -23,48 +24,30 @@ void testMain() { await ui.webOnlyInitializePlatform(); }); - test('CkAnimatedImage toString', () { - final SkAnimatedImage skAnimatedImage = - canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage); - final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage); - expect(image.toString(), '[1×1]'); - image.dispose(); - }); - test('CkAnimatedImage can be explicitly disposed of', () { - final SkAnimatedImage skAnimatedImage = - canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage); - final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage); + final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage); expect(image.box.isDeleted, false); expect(image.debugDisposed, false); image.dispose(); expect(image.box.isDeleted, true); expect(image.debugDisposed, true); - image.dispose(); - expect(image.box.isDeleted, true); - expect(image.debugDisposed, true); + + // Disallow double-dispose. + expect(() => image.dispose(), throwsAssertionError); }); test('CkAnimatedImage can be cloned and explicitly disposed of', () async { - final SkAnimatedImage skAnimatedImage = - canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage); - final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage); - final CkAnimatedImage imageClone = image.clone(); + final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage); + final SkAnimatedImage skAnimatedImage = image.box.skiaObject; + final SkiaObjectBox box = image.box; + expect(box.refCount, 1); + expect(box.debugGetStackTraces().length, 1); - expect(image.isCloneOf(imageClone), true); - expect(image.box.isDeleted, false); - await Future.delayed(Duration.zero); - expect(skAnimatedImage.isDeleted(), false); image.dispose(); - expect(image.box.isDeleted, true); - expect(imageClone.box.isDeleted, false); - await Future.delayed(Duration.zero); - expect(skAnimatedImage.isDeleted(), false); - imageClone.dispose(); - expect(image.box.isDeleted, true); - expect(imageClone.box.isDeleted, true); + expect(box.isDeleted, true); await Future.delayed(Duration.zero); expect(skAnimatedImage.isDeleted(), true); + expect(box.debugGetStackTraces().length, 0); }); test('CkImage toString', () { @@ -86,9 +69,9 @@ void testMain() { image.dispose(); expect(image.debugDisposed, true); expect(image.box.isDeleted, true); - image.dispose(); - expect(image.debugDisposed, true); - expect(image.box.isDeleted, true); + + // Disallow double-dispose. + expect(() => image.dispose(), throwsAssertionError); }); test('CkImage can be explicitly disposed of when cloned', () async { @@ -96,22 +79,27 @@ void testMain() { canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage) .getCurrentFrame(); final CkImage image = CkImage(skImage); + final SkiaObjectBox box = image.box; + expect(box.refCount, 1); + expect(box.debugGetStackTraces().length, 1); + final CkImage imageClone = image.clone(); + expect(box.refCount, 2); + expect(box.debugGetStackTraces().length, 2); expect(image.isCloneOf(imageClone), true); - expect(image.box.isDeleted, false); + expect(box.isDeleted, false); await Future.delayed(Duration.zero); expect(skImage.isDeleted(), false); image.dispose(); - expect(image.box.isDeleted, true); - expect(imageClone.box.isDeleted, false); + expect(box.isDeleted, false); await Future.delayed(Duration.zero); expect(skImage.isDeleted(), false); imageClone.dispose(); - expect(image.box.isDeleted, true); - expect(imageClone.box.isDeleted, true); + expect(box.isDeleted, true); await Future.delayed(Duration.zero); expect(skImage.isDeleted(), true); + expect(box.debugGetStackTraces().length, 0); }); test('skiaInstantiateWebImageCodec throws exception if given invalid URL', @@ -119,6 +107,15 @@ void testMain() { expect(skiaInstantiateWebImageCodec('invalid-url', null), throwsA(isA())); }); + + test('CkImage toByteData', () async { + final SkImage skImage = + canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage) + .getCurrentFrame(); + final CkImage image = CkImage(skImage); + expect((await image.toByteData()).lengthInBytes, greaterThan(0)); + expect((await image.toByteData(format: ui.ImageByteFormat.png)).lengthInBytes, greaterThan(0)); + }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); } diff --git a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart index 7de655bed97d3..aa1b808fa24d5 100644 --- a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart +++ b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart @@ -11,8 +11,8 @@ import 'package:test/test.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/src/engine.dart'; -import 'common.dart'; import '../matchers.dart'; +import 'common.dart'; void main() { internalBootstrapBrowserTest(() => testMain); @@ -158,42 +158,80 @@ void _tests() { group(SkiaObjectBox, () { test('Records stack traces and respects refcounts', () async { TestSkDeletable.deleteCount = 0; - final Object wrapper = Object(); - final SkiaObjectBox box = SkiaObjectBox(wrapper, TestSkDeletable()); - - expect(box.debugGetStackTraces().length, 1); + final TestBoxWrapper original = TestBoxWrapper(); - final SkiaObjectBox clone = box.clone(wrapper); - expect(clone, isNot(same(box))); - expect(clone.debugGetStackTraces().length, 2); - expect(box.debugGetStackTraces().length, 2); + expect(original.box.debugGetStackTraces().length, 1); + expect(original.box.refCount, 1); + expect(original.box.isDeleted, false); - box.delete(); + final TestBoxWrapper clone = original.clone(); + expect(clone.box, same(original.box)); + expect(clone.box.debugGetStackTraces().length, 2); + expect(clone.box.refCount, 2); + expect(original.box.debugGetStackTraces().length, 2); + expect(original.box.refCount, 2); + expect(original.box.isDeleted, false); - expect(() => box.clone(wrapper), throwsAssertionError); + original.dispose(); - expect(box.isDeleted, true); - - // Let any timers elapse. + // Let Skia object delete queue run. await Future.delayed(Duration.zero); expect(TestSkDeletable.deleteCount, 0); - expect(clone.debugGetStackTraces().length, 1); - expect(box.debugGetStackTraces().length, 1); + expect(clone.box.debugGetStackTraces().length, 1); + expect(clone.box.refCount, 1); + expect(original.box.debugGetStackTraces().length, 1); + expect(original.box.refCount, 1); - clone.delete(); - expect(() => clone.clone(wrapper), throwsAssertionError); + clone.dispose(); - // Let any timers elapse. + // Let Skia object delete queue run. await Future.delayed(Duration.zero); expect(TestSkDeletable.deleteCount, 1); - expect(clone.debugGetStackTraces().length, 0); - expect(box.debugGetStackTraces().length, 0); + expect(clone.box.debugGetStackTraces().length, 0); + expect(clone.box.refCount, 0); + expect(original.box.debugGetStackTraces().length, 0); + expect(original.box.refCount, 0); + expect(original.box.isDeleted, true); + + expect(() => clone.box.unref(clone), throwsAssertionError); }); }); } +/// A simple class that wraps a [SkiaObjectBox]. +/// +/// Can be [clone]d such that the clones share the same ref counted box. +class TestBoxWrapper implements StackTraceDebugger { + TestBoxWrapper() { + if (assertionsEnabled) { + _debugStackTrace = StackTrace.current; + } + box = SkiaObjectBox(this, TestSkDeletable()); + } + + TestBoxWrapper.cloneOf(this.box) { + if (assertionsEnabled) { + _debugStackTrace = StackTrace.current; + } + box.ref(this); + } + + @override + StackTrace get debugStackTrace => _debugStackTrace; + StackTrace _debugStackTrace; + + SkiaObjectBox box; + + void dispose() { + box.unref(this); + } + + TestBoxWrapper clone() => TestBoxWrapper.cloneOf(box); +} + + class TestSkDeletable implements SkDeletable { static int deleteCount = 0;