Multiple watch faces crashing simultaneously with "out of memory" error (minimal repro app included)

For the last 1-2 weeks, a few watch faces have been crashing on my Epix 2 after it's been running for more than one day. They all crash at the same time, and the only way to get them to load again is to restart the device.  I've generally ruled out a memory leak.

The source of the problem seems to be use of Graphics.createBufferedBitmap.  This call is "randomly" working fine and randomly causing an out-of-memory issue.  ("Randomly" is in quotes because it works fine when the device is freshly started, but after some unknown time, all watch faces making this call fail - including ones I made and one from the store).

Here's what I know:

  • The 3 watch faces I made (all still in private beta):
    • Generally use 70% - 80% memory (on device) - I added a gauge to see it realtime on all my faces
    • Memory usage remains constant according to this gauge - there's no memory leak / change in memory over multiple days of consistent use of the same watch face
    • 1 of the watch faces worked consistently well with no crashes (memory-related or otherwise) from Dec 1, 2022 through the end of December. The other 2 faces are brand new as of the last 1-2 weeks since the new year.
    • All my faces use BufferedBitmap to enable 1hz updating (with a second indicator).  For each minute, I draw to the BufferedBitmap and then draw that to the screen.  Then, between minutes, if the device is awake, I'll draw the background minute via the BufferedBitmap.  Then I'll also draw the second indicator directly to the DC over the top.  This allows me to keep the onUpdate call for each second to about 6 milliseconds of execution time.
    • All my watch faces use exactly 1 BufferedBitmap set to the full dimensions of the DC.  I use anti-aliasing, so I cannot use a pallet.  I also do not specify a color depth.  This BufferedBitmap is stored in a variable inside the class that extends WatchUi.WatchFace.
  • All watch faces (ones I made + one I didn't):
    • Work fine for the first day or so
    • They all crash together at the same time / they all work fine when the device is restarted
    • The crash generally happens when the watch face goes from not being displayed to being displayed again (e.g. after the Morning Report or after exiting an activity)

The watch face that's crashing that I didn't make is "Analog Switch"

About a week ago, I began some research into this issue by looking at the error logs on the device and tracking it back to the function where the Graphics.createBufferedBitmap call happens.

To test my hypothesis that this is a system-level issue (and not something related to the design of the watch faces), I created a minimum reproducible watch face (I call it MemoryX for "Memory Explorer"). To create it, I took the new Watch Face project and made very slight modifications to make it use a BufferedBitmap to draw the time to the screen (based on a setting).  Once the watch goes into the mode where all the watch faces crash, MemoryX loads fine if I turn off the use of the BufferedBitmap.  But if I toggle the setting to make it use the BufferedBitmap, it crashes, too. When using the BufferedBitmap, MemoryX only used 7.3/123.9kB on the simulator. But this crashes on the device.

Questions:

  • Is anyone else experiencing this or something related?  I noticed Gavriel reported another memory issue recently.
  • Why would they all crash at the same time?  Even if one of the faces had an out-of-memory issue, shouldn't that be isolated?
  • It's odd that this happens "randomly" after some time -- can anyone think of something I need to be cleaning up in onHide/onShow that I'm not?

I can't seem to post formatted code (it's telling me I'm blocked).  And I know I can't post images, either.  But here's the code (unformatted) inside the onUpdate function.  I'll try to post it more clearly in a comment below:

function onUpdate(dc as Dc) as Void {
var timeFormat = "$1$:$2$";
var clockTime = System.getClockTime();
var hours = clockTime.hour;
var timeString = Lang.format(timeFormat, [hours, clockTime.min.format("%02d")]);

if (getApp().getProperty("UseBB")){
if(bbDisplay == null){
//We need a BB but it's null - init it
bbDisplay = Graphics.createBufferedBitmap({
:width=>dc.getWidth(),
:height=>dc.getHeight(),
}).get();
}
}
else if(bbDisplay != null){
bbDisplay = null;
}

//Will be either main DC or BufferedBitmaps's DC
var drawContext;

//Will print whether we're using BB or not
var textPrint;

if(bbDisplay != null){
drawContext = bbDisplay.getDc();
textPrint = "Using BB";
}
else{
drawContext = dc;
textPrint = "NOT Using BB";
}

drawContext.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK);
drawContext.clear();

var font = Graphics.FONT_SYSTEM_SMALL;

//Draw time
drawContext.drawText(
drawContext.getWidth() / 2.0,
drawContext.getHeight() / 2.0 - Graphics.getFontHeight(font),
font,
timeString,
Graphics.TEXT_JUSTIFY_CENTER
);
//Draw whether using BB
drawContext.drawText(
drawContext.getWidth() / 2.0,
drawContext.getHeight() / 2.0,
font,
textPrint,
Graphics.TEXT_JUSTIFY_CENTER
);

if(bbDisplay != null){
dc.drawBitmap(0, 0, bbDisplay);
}
}
  • ,

    I think it would be helpful if you were to provide a test case for the issue that you're claiming. Our unit tests to memory leak checks and I've written a test app testing all of the methods of AffineTransform and am not able to reproduce any leak.

  • Hello Travis,

    By the way I like very much Flocsy's comment, please note that I answer in less than 24 hours, not 7 months.

    By the way I am joking, I appreciate your interest and your question :-)

    By the way we may talk about Polar and what i think about Garmin as you like.

    By the way let's focus on the issue.

    The error I get is:

    Exception: The requested memory could not be allocated from the graphics memory pool

    May be this is not a memory leak, may be this is the garbage collector which doesn't work, but I assume that there is a memory overflow at some point.

    Here after is a sample code that crashes in the simulator with Eclipse (I moved for some reason to Visual Studio recently, but I didn't try in Visual Studio).

    To reproduce: create a watch face and name it MemoryCrashTest, select the device Garmin Fenix 7X, remove the layout folder that I don't use, copy/paste the following code into memoryCrashtestView.mc file, save it, run it, in the simulator goto > Simulation > Time Simulation, set Factor = 400, click Start => it crashes in less than 30 seconds on my PC.

    Side Notes:

    This code can be use by any one to learn how works affine transform

    The code is not optimized at all, functions are welcomed to make better code

    The center point drawn at the end is not centered, you need extra code to center it with drawing a buffer and move it by half of pixel with a transform as

    affineTransform.translate(-0.5,-0.5);

    The workaround is very simple, set buffers  watchFaceBackgroundBuffer, indexBuffer, indexBuffer2, watchHandHourBuffer, watchHandMinuteBuffer in the APP file and use them with $. as

    if ($.buffer == null) {$.buffer = Graphics.createBufferedBitmap(bufferOptions); }

    If you don't do it also with the affine transform, it will also crash, later for sure but it will crash.

    Do the same with the affine transform, set it in the APP file, create only one time, and reuse the same everywhere and every time as:

    if ($.affine == null) {$.affine = Graphics.AffineTransform(); }

    $.affine.initialize();

    Hare after the source code MemoryCrashtestView.mc:

    import Toybox.Graphics;

    import Toybox.Lang;

    import Toybox.System;

    import Toybox.WatchUi;

    class MemoryCrashTestView extends WatchUi.WatchFace {

    var colBlack = 0x000000;

    var colWhite = 0xffffff;

    var colBack = 0x000055;

    var watchFaceBackgroundBuffer;

    function initialize() {

    WatchFace.initialize();

    }

    // Load your resources here

    function onLayout(dc as Dc) as Void {

    var bufferOptions = { :width=>dc.getWidth(), :height=>dc.getHeight() };

    watchFaceBackgroundBuffer = Graphics.createBufferedBitmap(bufferOptions);

    watchFaceBackgroundBuffer = watchFaceBackgroundBuffer.get();

    }

    // Update the view

    function onUpdate(dc as Dc) as Void {

    fillBackground(dc);

    // Draw watch

    dc.clearClip();

    // Draw the background with indexes and dial

    dc.drawBitmap(0, 0, watchFaceBackgroundBuffer);

    }

    function buildHand(width, height) {

    var tempBuffer = Graphics.createBufferedBitmap({ :width=>width, :height=>height });

    var tempHand = tempBuffer.get().getDc();

    tempHand.setColor(Graphics.COLOR_TRANSPARENT, Graphics.COLOR_TRANSPARENT);

    tempHand.clear();

    tempHand.setColor(colBlack, Graphics.COLOR_TRANSPARENT);

    tempHand.fillRectangle(1, 1, width-2, height-2);

    tempHand.setColor(colWhite, Graphics.COLOR_TRANSPARENT);

    tempHand.fillRectangle(2, 2, width-4, height-4);

    return tempBuffer;

    }

    function fillBackground(dc) {

    // INIT BACKGROUND BUFFER

    var dcBuffer = watchFaceBackgroundBuffer.getDc();

    dcBuffer.clearClip();

    dcBuffer.setColor(colBack, colBack);

    dcBuffer.clear();

    dcBuffer.setAntiAlias(true);

    // DRAW INDEX SECOND

    var indexBuffer = Graphics.createBufferedBitmap({ :width=>4, :height=>10 });

    var tempIndex = indexBuffer.get().getDc();

    tempIndex.setColor(Graphics.COLOR_TRANSPARENT, Graphics.COLOR_TRANSPARENT);

    tempIndex.clear();

    tempIndex.setColor(colWhite, Graphics.COLOR_TRANSPARENT);

    tempIndex.fillRectangle(1, 1, 2, 8);

    for (var s = 0; s < 60; s++) {

    if ((s % 5) == 0) { continue; }

    var indexAngle = (s / 60.0) * Math.PI * 2;

    var newX = dc.getWidth()/2 + Math.cos(Math.PI/2.0 - indexAngle)*(dc.getWidth()*0.49);

    var newY = dc.getHeight()/2 - Math.sin(Math.PI/2.0 - indexAngle)*(dc.getHeight()*0.49);

    var translateMatrixIndex = new Graphics.AffineTransform();

    translateMatrixIndex.initialize();

    translateMatrixIndex.translate(newX,newY);

    translateMatrixIndex.rotate(indexAngle);

    translateMatrixIndex.translate(-2.0,0.0);

    dcBuffer.drawBitmap2(0, 0, indexBuffer, {

    :transform => translateMatrixIndex,

    :filterMode => Graphics.FILTER_MODE_BILINEAR

    });

    }

    // DRAW INDEX HOUR

    var indexBuffer2 = Graphics.createBufferedBitmap({ :width=>10, :height=>10 });

    var tempIndex2 = indexBuffer2.get().getDc();

    tempIndex2.setColor(Graphics.COLOR_TRANSPARENT, Graphics.COLOR_TRANSPARENT);

    tempIndex2.clear();

    tempIndex2.setColor(colWhite, Graphics.COLOR_TRANSPARENT);

    tempIndex2.fillRectangle(1, 1, 8, 8);

    for (var s = 0; s < 12; s++) {

    var indexAngle = ((s*5 / 60.0)) * Math.PI * 2;

    var newX = dc.getWidth()/2 + Math.cos(Math.PI/2.0 - indexAngle)*(dc.getWidth()*0.49);

    var newY = dc.getHeight()/2 - Math.sin(Math.PI/2.0 - indexAngle)*(dc.getHeight()*0.49);

    var translateMatrixIndex = new Graphics.AffineTransform();

    translateMatrixIndex.initialize();

    translateMatrixIndex.translate(newX,newY);

    translateMatrixIndex.rotate(indexAngle);

    translateMatrixIndex.translate(-5.0,0.0);

    dcBuffer.drawBitmap2(0, 0, indexBuffer2, {

    :transform => translateMatrixIndex,

    :filterMode => Graphics.FILTER_MODE_BILINEAR

    });

    }

    // DRAW HOUR DIAL

    dcBuffer.setColor(colWhite, Graphics.COLOR_TRANSPARENT);

    for (var s = 0; s < 12; s++) {

    var indexAngle = ((s*5 / 60.0)+(5.0/60.0)) * Math.PI * 2;

    var newX = dc.getWidth()/2 + Math.cos(Math.PI/2.0 - indexAngle)*(dc.getWidth()*0.41);

    var newY = dc.getHeight()/2 - Math.sin(Math.PI/2.0 - indexAngle)*(dc.getHeight()*0.39);

    dcBuffer.drawText(newX, newY, Graphics.FONT_MEDIUM, (s+1), Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);

    }

    // GET CLOCK TIME

    var clockTime = System.getClockTime();

    // Hour angle

    var hourHandAngle = (((clockTime.hour % 12) * 60) + clockTime.min);

    hourHandAngle = hourHandAngle / (12 * 60.0);

    hourHandAngle = hourHandAngle * Math.PI * 2;

    // Minute angle

    //var minuteHandAngle = (clockTime.min * 60.0);

    var minuteHandAngle = (clockTime.min * 60.0);

    minuteHandAngle = (minuteHandAngle / 60.0 / 60.0) * Math.PI * 2;

    // DRAW HANDS

    var hourTail = 20;

    var hourHeight = dc.getHeight() * 0.33;

    var hourWidth = dc.getHeight() * 0.06;

    var watchHandHourBuffer = buildHand(hourWidth, hourHeight);

    var minuteTail = 14;

    var minuteHeight = dc.getHeight() * 0.50;

    var minuteWidth = dc.getHeight() * 0.05;

    var watchHandMinuteBuffer = buildHand(minuteWidth, minuteHeight);

    // DRAW HOUR

    var newXH = dc.getWidth()/2 + Math.cos(Math.PI/2.0 - hourHandAngle)*(hourHeight-hourTail);

    var newYH = dc.getHeight()/2 - Math.sin(Math.PI/2.0 - hourHandAngle)*(hourHeight-hourTail);

    var translateMatrixHour = new Graphics.AffineTransform();

    translateMatrixHour.initialize();

    translateMatrixHour.translate(newXH,newYH);

    translateMatrixHour.rotate(hourHandAngle);

    translateMatrixHour.translate(-(hourWidth.toFloat()/2.0),0.0);

    dcBuffer.drawBitmap2(0, 0, watchHandHourBuffer, {

    :transform => translateMatrixHour,

    :filterMode => Graphics.FILTER_MODE_BILINEAR

    });

    // DRAW MINUTE

    var newXM = dc.getWidth()/2 + Math.cos(Math.PI/2.0 - minuteHandAngle)*(minuteHeight-minuteTail);

    var newYM = dc.getHeight()/2 - Math.sin(Math.PI/2.0 - minuteHandAngle)*(minuteHeight-minuteTail);

    var translateMatrixMinute = new Graphics.AffineTransform();

    translateMatrixMinute.initialize();

    translateMatrixMinute.translate(newXM,newYM);

    translateMatrixMinute.rotate(minuteHandAngle);

    translateMatrixMinute.translate(-(minuteWidth.toFloat()/2.0),0.0);

    dcBuffer.drawBitmap2(0, 0, watchHandMinuteBuffer, {

    :transform => translateMatrixMinute,

    :filterMode => Graphics.FILTER_MODE_BILINEAR

    });

    // DRAW CENTER

    dcBuffer.setColor(colBlack, Graphics.COLOR_TRANSPARENT);

    dcBuffer.fillCircle(dc.getWidth()/2, dc.getHeight()/2, 3);

    }

    }

  • Hi Flocsy,

    My work horse watch and every day watch is now a Crossover, that I use all time including running.

    I have another watch that I use when I need a map+track+color+large screen+long last battery, there is more choice for this one ;)

  • In this sample code, in fact we don't need the watchFaceBackgroundBuffer buffer and we may draw everything directly on the dc.

    But I did this code quickly with many copy/paste. If we want to use the onPartialUpdate to draw the second hand, then we would need this buffer (redraw dc with the buffer, set new clip, draw second hand).

    I you like this code, I can share a code with the workaround + second hand draw, but please first replicate the issue ;)