OutOfMemory и GDI+ иногда совсем не OutOfMemory

habr.png

При выполнении последнего проекта на работе мы с коллегой столкнулись с тем, что некоторые методы и конструкторы в System.Drawing падают с OutOfMemory в совершенно обычных местах и когда памяти свободной ещё очень и очень много.

Суть проблемы


Для примера возьмём этот код на C#:

using System.Drawing;
using System.Drawing.Drawing2D;
namespace TempProject {
    static class Program {
        static void Main() {
            var point1 = new PointF(-3.367667E-16f, 0f);
            var point2 = new PointF(3.367667E-16f, 100f);
            var brush = new LinearGradientBrush(point1, point2, Color.White, Color.Black);
        }
    }
}


При выполнении последней строчки гарантировано будет выброшено исключение OutOfMemoryException, независимо от того, сколько свободной памяти имеется. Причём, если заменить 3.367667E-16f и -3.367667E-16f на 0, что очень близко к правде, всё будет работать нормально — заливка будет создана. На мой взгляд, такое поведение выглядит странным. Давайте разберёмся, от чего это происходит и как с этим бороться.

Выясняем причины болезни


Начнём с того, что узнаем, что происходит в конструкторе LinearGradientBrush. Для этого можно заглянуть на referencesource.microsoft.com. Там будет следующее:

public LinearGradientBrush(PointF point1, PointF point2, Color color1, Color color2) {
    IntPtr brush = IntPtr.Zero;
    int status = SafeNativeMethods.Gdip.GdipCreateLineBrush(
                    new GPPOINTF(point1), 
                    new GPPOINTF(point2), 
                    color1.ToArgb(), 
                    color2.ToArgb(), 
                    (int)WrapMode.Tile, 
                    out brush
    );
    if (status != SafeNativeMethods.Gdip.Ok) 
        throw SafeNativeMethods.Gdip.StatusException(status);
    SetNativeBrushInternal(brush); 
}


Несложно заметить, что самое главное тут — вызов GDI+ метода GdipCreateLineBrush. Значит, необходимо смотреть что происходит внутри него. Для этого воспользуемся IDA + HexRays. Загрузим в IDA gdiplus.dll. Если надо определить, какую именно версию библиотеки отлаживать, то можно воспользоваться Process Explorer от SysInternals. Кроме того, могут возникнуть проблемы с правами на папку, где лежит gdiplus.dll. Они решаются сменой владельца этой папки.
Итак, откроем gdiplus.dll в IDA. Дождёмся обработки файла. После этого выберем в меню: View → Open Subviews → Exports, чтобы открыть все функции, которые экспортируются из этой библиотеки, и найдём там GdipCreateLineBrush.
Благодаря загрузке символов, мощности HexRays и документации можно без труда перевести код метода из ассемблера в читабельный код на С++:

GdipCreateLineBrush
GpStatus __userpurge GdipCreateLineBrush@(int a1@, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode, GpRectGradient **result)
{
  GpStatus status; // esi MAPDST
  GpGradientBrush *v8; // eax
  GpRectGradient *v9; // eax
  int v12; // [esp+4h] [ebp-Ch]
  int vColor1; // [esp+8h] [ebp-8h]
  int vColor2; // [esp+Ch] [ebp-4h]

  FPUStateSaver::FPUStateSaver(&v12, 1);
  EnterCriticalSection(&GdiplusStartupCriticalSection::critSec);
  if ( Globals::LibraryInitRefCount > 0 )
  {
    LeaveCriticalSection(&GdiplusStartupCriticalSection::critSec);
    if ( result && point1 && point2 && wrapMode != 4 )
    {
      vColor1 = color1;
      vColor2 = color2;
      v8 = operator new(a1);
      status = 0;
      if ( v8 )
        v9 = GpLineGradient::GpLineGradient(v8, point1, point2, &vColor1, &vColor2, wrapMode);
      else
        v9 = 0;
      *result = v9;
      if ( !CheckValid(result) )
        status = OutOfMemory;
    }
    else
    {
      status = InvalidParameter;
    }
  }
  else
  {
    LeaveCriticalSection(&GdiplusStartupCriticalSection::critSec);
    status = GdiplusNotInitialized;
  }
  __asm { fclex }
  return status;
}


Код этого метода абсолютно понятен. Его суть заключена в строках:

if ( result && point1 && point2 && wrapMode != 4 )
{
  vColor1 = color1;
  vColor2 = color2;
  v8 = operator new(a1);
  status = 0;
  if ( v8 )
    v9 = GpLineGradient::GpLineGradient(v8, point1, point2, &vColor1, &vColor2, wrapMode);
  else
    v9 = 0;
  *result = v9;
  if ( !CheckValid(result) )
    status = OutOfMemory
} 
else {
  status = InvalidParameter;
}


GdiPlus проверяет, верны ли входные параметры и, если это не так, то возвращает InvalidParameter. В противном же случае создаётся GpLineGradient и проверяется на валидность. Если валидация не пройдена, то возвращается OutOfMemory. Видимо, это наш случай, а, значит, надо разобраться, что происходит внутри конструктора GpLineGradient:

GpLineGradient: GpLineGradient
GpRectGradient *__thiscall GpLineGradient::GpLineGradient(GpGradientBrush *this, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode)
{
  GpGradientBrush *v6; // esi
  float height; // ST2C_4
  double v8; // st7
  float width; // ST2C_4
  float angle; // ST2C_4
  GpRectF rect; // [esp+1Ch] [ebp-10h]

  v6 = this;
  GpGradientBrush::GpGradientBrush(this);
  GpRectGradient::DefaultBrush(v6);
  rect.Height = 0.0;
  rect.Width = 0.0;
  rect.Y = 0.0;
  rect.X = 0.0;
  *v6 = &GpLineGradient::`vftable;
  if ( LinearGradientRectFromPoints(point1, point2, &rect) )
  {
    *(v6 + 1) = 1279869254;
  }
  else
  {
    height = point2->Y - point1->Y;
    v8 = height;
    width = point2->X - point1->X;
    angle = atan2(v8, width) * 180.0 / 3.141592653589793;
    GpLineGradient::SetLineGradient(v6, point1, point2, &rect, color1, color2, angle, 0, wrapMode);
  }
  return v6;
}


Здесь происходит инициализация переменных, которые потом заполняются в LinearGradientRectFromPoints и SetLineGradient. Смею предположить, что rect — это прямоугольник заливки, основанный на point1 и point2, чтобы убедиться в этом можно заглянуть в LinearGradientRectFromPoints:

LinearGradientRectFromPoints
GpStatus __fastcall LinearGradientRectFromPoints(GpPointF *p1, GpPointF *p2, GpRectF *result)
{
  double vP1X; // st7
  float vLeft; // ST1C_4 MAPDST
  double vP1Y; // st7
  float vTop; // ST1C_4 MAPDST
  float vWidth; // ST18_4 MAPDST
  double vWidth3; // st7
  float vHeight; // ST18_4 MAPDST
  float vP2X; // [esp+18h] [ebp-8h]
  float vP2Y; // [esp+1Ch] [ebp-4h]

  if ( IsClosePointF(p1, p2) )
    return InvalidParameter;
  vP2X = p2->X;
  vP1X = p1->X;
  if ( vP2X <= vP1X )
    vP1X = vP2X;
  vLeft = vP1X;
  result->X = vLeft;
  vP2Y = p2->Y;
  vP1Y = p1->Y;
  if ( vP2Y <= vP1Y )
    vP1Y = vP2Y;
  vTop = vP1Y;
  result->Y = vTop;
  vWidth = p1->X - p2->X;
  vWidth = fabs(vWidth);
  vWidth3 = vWidth;
  result->Width = vWidth;
  vHeight = p1->Y - p2->Y;
  vHeight = fabs(vHeight);
  result->Height = vHeight;
  vWidth = vWidth3;
  if ( IsCloseReal(p1->X, p2->X) )
  {
    result->X = vLeft - 0.5 * vHeight;
    result->Width = vHeight;
    vWidth = vHeight;
  }
  if ( IsCloseReal(p1->Y, p2->Y) )
  {
    result->Y = vTop - vWidth * 0.5;
    result->Height = vWidth;
  }
  return 0;
}


Как и предполагалось, rect — прямоугольник из точек point1 и point2.
Теперь вернёмся к нашей основной проблеме и разберёмся что происходит внутри SetLineGradient:

SetLineGradient
GpStatus __thiscall GpLineGradient::SetLineGradient(DpGradientBrush *this, GpPointF *p1, GpPointF *p2, GpRectF *rect, int color1, int color2, float angle, int zero, int wrapMode)
{
  _DWORD *v10; // edi
  float *v11; // edi
  GpStatus v12; // esi
  _DWORD *v14; // edi

  this->wrapMode = wrapMode;
  v10 = &this->dword40;
  this->Color1 = *color1;
  this->Color2 = *color2;
  this->Color11 = *color1;
  this->Color21 = *color2;
  this->dwordB0 = 0;
  this->float98 = 1.0;
  this->dwordA4 = 1;
  this->dwordA0 = 1;
  this->float94 = 1.0;
  this->dwordAC = 0;
  if ( CalcLinearGradientXform(zero, rect, angle, &this->gap4[16]) )
  {
    *this->gap4 = 1279869254;
    *v10 = 0;
    v14 = v10 + 1;
    *v14 = 0;
    ++v14;
    *v14 = 0;
    v14[1] = 0;
    *&this[1].gap4[12] = 0;
    *&this[1].gap4[16] = 0;
    *&this[1].gap4[20] = 0;
    *&this[1].gap4[24] = 0;
    *&this->gap44[28] = 0;
    v12 = InvalidParameter;
  }
  else
  {
    *this->gap4 = 1970422321;
    *v10 = LODWORD(rect->X);
    v11 = (v10 + 1);
    *v11 = rect->Y;
    ++v11;
    *v11 = rect->Width;
    v11[1] = rect->Height;
    *&this->gap44[28] = zero;
    v12 = 0;
    *&this[1].gap4[12] = *p1;
    *&this[1].gap4[20] = *p2;
  }
  return v12;
}


В SetLineGradient тоже происходит только инициализация полей. So, we need to go deeper:

int __fastcall CalcLinearGradientXform(int zero, GpRectF *rect, float angle, int a4)
{
  //...
  //...
  //...
  return GpMatrix::InferAffineMatrix(a4, points, rect) != OK ? InvalidParameter : OK;
}


И, наконец:

GpStatus __thiscall GpMatrix::InferAffineMatrix(int this, GpPointF *points, GpRectF *rect)
{
  //...
  double height; // st6
  double y; // st5
  double width; // st4
  double x; // st3
  double bottom; // st2
  float right; // ST3C_4
  float rectArea; // ST3C_4
  //...

  x = rect->X;
  y = rect->Y;
  width = rect->Width;
  height = rect->Height;
  right = x + width;  
  bottom = height + y;
  rectArea = bottom * right - x * y - (y * width + x * height);
  rectArea = fabs(rectArea);
  if ( rectArea < 0.00000011920929 )
    return InvalidParameter;
  //...
}


В методе InferAffineMatrix происходит именно то, что нас интересует. Тут проверяется площадь rect — исходного прямоугольника из точек, и если она меньше, чем 0.00000011920929, то InferAffineMatrix возвращает InvalidParameter. 0.00000011920929 — это машинный эпсилон для float (FLT_EPSILON). Можно заметить, как интересно в Microsoft считают площадь прямоугольника:

rectArea = bottom * right - x * y - (y * width + x * height);

Из площади до правого нижнего угла вычитают площадь до верхнего левого, затем вычитают площадь над прямоугольником и слева от прямоугольника. Зачем так сделано, мне не понятно; надеюсь, когда-нибудь я познаю этот тайный метод.

Итак, что мы имеем:

  • InnerAffineMatrix возвращает InvalidParameter;
  • CalcLinearGradientXForm пробрасывает этот результат выше;
  • В SetLineGradient выполнение пойдёт по ветке if и метод тоже вернёт InvalidParameter;
  • Конструктор GpLineGradient потеряет информацию об InvalidParameter и вернёт неинициализированный до конца объект GpLineGradient — это очень плохо!
  • GdipCreateLineBrush проверит в CheckValid (строка 26) на правильность объект GpLineGradient с незаполненными до конца полями и закономерно вернёт false.
  • После этого status поменяется на OutOfMemory, что и получит .NET на выходе из GDI+ метода.


Выходит, что Microsoft зачем-то игнорирует возвращаемый статус некоторых методов, делает из-за этого неверные предположения и усложняет понимание работы библиотеки для других программистов. Но ведь всего-то надо было из конструктора GpLineGradient пробрасывать статус выше, а в GdipCreateLineBrush проверять возвращаемое значение на OK и в противном случае возвращать статус конструктора. Тогда для пользователей GDI+ сообщение об ошибке, произошедшей внутри библиотеки, выглядело бы более логичным.
Вариант с заменой очень маленьких чисел на ноль, т.е. с вертикальной заливкой, выполняется без ошибок из-за магии, которую Microsoft выполняет в методе LinearGradientRectFromPoints в строках с 35 по 45:

Магия
if ( IsCloseReal(p1->X, p2->X) )
{
  result->X = vLeft - 0.5 * vHeight;
  result->Width = vHeight;
  vWidth = vHeight;
}
if ( IsCloseReal(p1->Y, p2->Y) )
{
  result->Y = vTop - vWidth * 0.5;
  result->Height = vWidth;
}


Как лечить?


Как же избежать этого падения в .NET коде? Самый простой и очевидный вариант — сравнить площадь прямоугольника из точек point1 и point2 с FLT_EPSILON и не создавать градиент, если площадь меньше. Но при таком варианте мы потеряем информацию о градиенте и нарисуется незакрашенная область, что нехорошо. Мне видится более приемлемым вариант, когда проверяется угол градиентной заливки и если выясняется, что заливка близка к горизонтальной или вертикальной, то выставляем одинаковыми соответствующие параметры у точек:

Моё решение на C#
static LinearGradientBrush CreateBrushSafely(PointF p1, PointF p2) {
    if(IsShouldNormalizePoints(p1, p2)) {
        if(!NormalizePoints(ref p1, ref p2))
            return null;
    }
    var brush = new LinearGradientBrush(p1, p2, Color.White, Color.Black);
    return brush;
}

static bool IsShouldNormalizePoints(PointF p1, PointF p2) {
    float width = Math.Abs(p1.X - p2.X);
    float height = Math.Abs(p1.Y - p2.Y);
    return width * height < FLT_EPSILON && !(IsCloseFloat(p1.X, p2.X) || IsCloseFloat(p1.Y, p2.Y));
}

static bool IsCloseFloat(float v1, float v2) {
    var t = v2 == 0.0f ? 1.0f : v2;
    return Math.Abs((v1 - v2) / t) < FLT_EPSILON;
}

static bool NormalizePoints(ref PointF p1, ref PointF p2) {
    const double twoDegrees = 0.03490658503988659153847381536977d;
    float width = Math.Abs(p1.X - p2.X);
    float height = Math.Abs(p1.Y - p2.Y);
    var angle = Math.Atan2(height, width);
    if (Math.Abs(angle) < twoDegrees) {
        p1.Y = p2.Y;
        return true;
    }
    if (Math.Abs(angle - Math.PI / 2) < twoDegrees) {
        p1.X = p2.X;
        return true;
    }
    return false;
}


А как дела у конкурентов?


Давайте узнаем что происходит в Wine. Для этого посмотрим на исходный код Wine, строка 306:

GdipCreateLineBrush из Wine
/******************************************************************************
 * GdipCreateLineBrush [GDIPLUS.@]
 */
GpStatus WINGDIPAPI GdipCreateLineBrush(GDIPCONST GpPointF* startpoint,
    GDIPCONST GpPointF* endpoint, ARGB startcolor, ARGB endcolor,
    GpWrapMode wrap, GpLineGradient **line)
{
    TRACE("(%s, %s, %x, %x, %d, %p)\n", debugstr_pointf(startpoint),
          debugstr_pointf(endpoint), startcolor, endcolor, wrap, line);

    if(!line || !startpoint || !endpoint || wrap == WrapModeClamp)
        return InvalidParameter;

    if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y)
        return OutOfMemory;

    *line = heap_alloc_zero(sizeof(GpLineGradient));
    if(!*line)  return OutOfMemory;

    (*line)->brush.bt = BrushTypeLinearGradient;

    (*line)->startpoint.X = startpoint->X;
    (*line)->startpoint.Y = startpoint->Y;
    (*line)->endpoint.X = endpoint->X;
    (*line)->endpoint.Y = endpoint->Y;
    (*line)->startcolor = startcolor;
    (*line)->endcolor = endcolor;
    (*line)->wrap = wrap;
    (*line)->gamma = FALSE;

    (*line)->rect.X = (startpoint->X < endpoint->X ? startpoint->X: endpoint->X);
    (*line)->rect.Y = (startpoint->Y < endpoint->Y ? startpoint->Y: endpoint->Y);
    (*line)->rect.Width  = fabs(startpoint->X - endpoint->X);
    (*line)->rect.Height = fabs(startpoint->Y - endpoint->Y);

    if ((*line)->rect.Width == 0)
    {
        (*line)->rect.X -= (*line)->rect.Height / 2.0f;
        (*line)->rect.Width = (*line)->rect.Height;
    }
    else if ((*line)->rect.Height == 0)
    {
        (*line)->rect.Y -= (*line)->rect.Width / 2.0f;
        (*line)->rect.Height = (*line)->rect.Width;
    }

    (*line)->blendcount = 1;
    (*line)->blendfac = heap_alloc_zero(sizeof(REAL));
    (*line)->blendpos = heap_alloc_zero(sizeof(REAL));

    if (!(*line)->blendfac || !(*line)->blendpos)
    {
        heap_free((*line)->blendfac);
        heap_free((*line)->blendpos);
        heap_free(*line);
        *line = NULL;
        return OutOfMemory;
    }

    (*line)->blendfac[0] = 1.0f;
    (*line)->blendpos[0] = 1.0f;

    (*line)->pblendcolor = NULL;
    (*line)->pblendpos = NULL;
    (*line)->pblendcount = 0;

    linegradient_init_transform(*line);

    TRACE("<-- %p\n", *line);

    return Ok;
}


Здесь есть единственная проверка параметров на валидность:

if(!line || !startpoint || !endpoint || wrap == WrapModeClamp)
    return InvalidParameter;


Скорее всего следующее было написано для совместимости с Windows:

if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y)
    return OutOfMemory;


А в остальном нет ничего интересного — выделение памяти и заполнение полей. Из исходного кода становится очевидно, что в Wine создание проблемной градиентной заливки должно выполняться без ошибок. И действительно — если запустить следующую программу в Windows (я запускал в Windows10×64)

Тестовая программа
#include 
#include "stdafx.h"

#include 
#include 
#pragma comment(lib,"gdiplus.lib")

void CreateBrush(float x1, float x2) {
 Gdiplus::LinearGradientBrush linGrBrush(
  Gdiplus::PointF(x1, -0.5f),
  Gdiplus::PointF(x2, 10.5f),
  Gdiplus::Color(255, 0, 0, 0),
  Gdiplus::Color(255, 255, 255, 255));
 const int status = linGrBrush.GetLastStatus();
 const char* result;
 if (status == 3) {
  result = "OutOfMemory";
 }
 else {
  result = "Ok";
 }
 std::cout << result << "\n";
}

int main() {
 Gdiplus::GdiplusStartupInput gdiplusStartupInput;
 ULONG_PTR gdiplusToken;
 Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL);

 Gdiplus::Graphics myGraphics(GetDC(0));
 CreateBrush(-3.367667E-16f, 3.367667E-16f);
 CreateBrush(0, 0);
    return 0;
}


То в консоли Windows будет:

OutOfMemory
Ok

, а в Ubuntu c Wine:

Ok
Ok

Выходит, что либо я что-то делаю не так, либо Wine в этом вопросе работает логичнее, чем Windows.

Заключение


Я очень надеюсь, что это я что-то не понял и поведение GDI+ является логичным. Правда, совсем не понятно зачем Microsoft всё сделала именно так. Я много копался в других их продуктах, и там тоже встречаются такие вещи, которые в приличном обществе точно бы не прошли Code Review.

© Habrahabr.ru