.NET dynamic, Unity и ошибка в RuntimeBinder

в 21:31, , рубрики: .net, dynamic, exception, unity, метки: , , , ,

Предыстория

Новая версия проекта была успешно завершена, протестирована, и уже устанавливалась клиентам. Ничего не предвещало беды, все шло по плану и можно было немного расслабиться.
Неожиданно, от всех клиентов стали поступать жалобы на ошибки, которые посыпались при попытке использовать новую версию программы. Это было очень странно, т.к. все было проверено и должно было работать как часы, но этого не произошло.

Ошибка, которая появилась на всех рабочих станциях, где была установлена новая версия программы, выглядела так:

System.IndexOutOfRangeException: Index was outside the bounds of the array
at Microsoft.CSharp.RuntimeBinder.ExpressionTreeCallRewriter.GetMethodInfoFromExpr(EXPRMETHODINFO methinfo)
...

И, как потом выяснилось, появлялась она в безобидном, на первый взгляд, куске кода:

public void FillFrom(dynamic launch)
{
  Log.ShowLog(launch.Id);
}

Из-за того, что у клиентов на компьютерах стоит Windows XP, мы ограничены в использовании .NET Framework'ом 4.0 версией, т.к. версию выше на XP поставить уже нельзя. Поэтому, наш проект нацелен на использование именно этой версии фреймворка, несмотря на то, что на наших компьютерах давно стоит VS 2012 и фреймворк 4.5. Это и повлияло на отсутствия ошибки у нас. Поэтому, пришлось выяснить, что же все таки стало причиной этой ошибки и как нам с ней бороться.

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

Из-за использования dynamic объекта при вызове метода компилятор превращает эту строчку кода в целый кусок. И в этом сгенерированном коде, вместо прямого вызова метода, происходит определение метода в процессе выполнения. Так называемое «позднее связывание». Т.е., у объекта Log в процессе выполнения, определенным образом ищется метод «ShowLog» для дальнейшего вызова. Сгенерированный компилятором код использует RuntimeBinder для поиска нужного метода. Но, что-то пошло не так и в процессе выполнения находился неправильный метод. Именно при анализе его параметров и происходила указанная выше ошибка, потому что их количество не совпало с ожидаемым.

Причина проблемы

Но как же такое могло произойти? Оказывается, RuntimeBinder изначально находит нужный метод и потом, в своих недрах берёт все доступные методы у конкретного типа и затем сравнивает их metadata token с токеном найденного метода. При совпадении токенов, биндер берёт совпавший метод и пытается анализировать его параметры, что в нашем случае приводит к ошибке.
Токены могли совпасть только у методов из разных сборок, т.к. в рамках одной сборки номера токенов не могли бы пересечься. И действительно, такая возможность была, т.к. класс, в котором возникла проблема, был наследником класса из другой сборки.

Теперь все казалось простым — быстро написать небольшой тестовый пример из двух сборок, иерархии двух классов и пары методов. Добиться того, чтобы токен метода наследника и метода базового класса совпали, и вызвать метод наследника с использованием динамической переменной. Но не тут-то было. Хоть пример оказался и весьма небольшим и токены совпадали — ничего не происходило и все работало должным образом.

Капнув чуть глубже выяснилось, что RuntimeBinder для поиска подходящего метода берет не все подряд, а только определенные методы, которые попадают под действие определенного фильтра:

BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic

Таким образом, стало понятно, что методы из базового класса никаким образом в поиске нужного не участвуют.

Покопав дальше, стало ясно, что токен метода совпал с токеном не «обычного» метода из базового класса, а с аксессором свойства, которое было определено в базовом классе. Т.е., токен метода «ShowLog» совпал с токеном метода «get_IsNotifying». А вот уже этот аксессор прекрасно проходил вышеуказанный фильтр.

Казалось, что ответ, наконец-то, найден и тестовый пример был немного скорректирован — в базовом классе появилось свойство, токен метода get_… которого совпадал с токеном методом «ShowLog». Но, несмотря на все попытки, тестовый пример работал без ошибок. Методы get_ и set_ из базового класса хоть и проходили фильтр, но находились в конце списка всех отобранных методов, в начале которого находился правильный метод «ShowLog», который успешно определялся RuntimeBinder'ом.

Роль Unity

Но не зря в заголовке темы присутствует название Unity. Это dependency injection , контейнер от майкрософт, который мы используем в проекте.
Как выяснилось, методом тыка, его участие как-то влияло на появление этой проблемы. Пришлось смотреть, что же такого необычного делает контейнер. После небольшого изучения, стало понятно, что суть его работы такая: чтобы создать экземпляр конкретного типа, он генерирует специальный метод.
Код этого метода заполняется несколькими, предопределенными стратегиями. В нашем случае это были три стратегии:

  • Стратегия, которая перебирала все конструкторы у класса для constructor injection
  • Стратегия, которая перебирала все свойства класса для для property injection
  • Стратегия, которая перебирала все методы класса для method injection

Выполнялись они именно в такой последовательности. Как выяснилось, именно в порядке этого перебора был спрятан последний кусочек пазла.

Если просто создать экземпляр какого-то типа, а потом перечислить все методы этого типа, то, в первую очередь, будут идти обычные метода, а лишь затем уже аксессоры свойств и событий. Но, если до первого создания объекта этого типа перечислить, например, все свойства, то аксессоры, из которых они состоят попадут в начало списка.

Получается, чтобы спровоцировать ошибку и заставить RuntimeBinder выбирать неправильный метод, нужно чтобы аксессор с подходящим токеном шел по списку перед правильным методом. Чтобы аксессор был в начале списка достаточно было вызвать, до первого создания объекта данного типа, следующий код:

typeof(Log).GetEvents();
typeof(Log).GetProperties();
new Log();

Благодаря такой последовательности действий, список методов данного типа сначала заполняется аксессорами событий, потом свойств. А затем, после создания объекта, заполняется всем остальным. После этого, воспроизвести проблему на небольшом тестовом примере оказалось проще простого.

Различия в фреймворках

Почему же проблема проявлялась только на тех машинах, где был 4 фреймворк, а на других нет, не смотря на то, что проект нацелен на 4 фреймворк? Как оказалось, в 4.5 фреймворке версия Microsoft.CSharp.dll отличается от версии в 4 фреймворке, хоть и незначительно: В 4 фреймворке это версия за номером 4.0.30319.1, а в 4.5 это версия 4.0.30319.17929, в которой, видимо, успели поправить некоторые ошибки.

Если взглянуть на код проблемного метода, то он изменился совсем немного, было:

MethodInfo[] methods = type.GetMethods(BindingFlags.Instance ...);
for (int i = 0; i < methods.Length; i++)
{
if (methods[i].MetadataToken == methodInfo.MetadataToken)
...

стало:

MethodInfo[] methods = type.GetMethods(BindingFlags.Instance...);
for (int i = 0; i < methods.Length; i++)
{
if (methods[i].MetadataToken == methodInfo.MetadataToken && !(methods[i].Module != methodInfo.Module))
...

Так что, таким вот двойным отрицанием этот баг был исправлен в 4.5 фреймворке.

Последствия ошибки

Как выяснилось опытным путем, последствия такой ошибки могут быть и другими, ведь аксессоры есть не только у свойств, но и у событий. И у некоторых акксессоров есть параметры. А у аксессеров индексаторов — параметров может вообще быть различное количество.

Получается, что могут возникнуть ошибки следующего типа, если RuntimeBinder выбирает неправильный метод:

  • если выбран метод с меньшим числом параметров — появляется ошибка IndexOutOfRangeException
  • если выбран метод с таким же числом параметров, типы которых полностью совпадают с ожидаемыми, — тогда просто будет вызван неправильный метод, а если метод что-то возвращает, то вернется результат работы неправильного метода.
  • если выбран метод с большим числом параметров, и типы необходимого числа параметры полностью совпали с ожидаемыми, то будет вызван неправильный метод и произойдет ошибка ArgumentException: Incorrect number of arguments supplied for call to method

Как с этим жить?

На connect.microsoft.com данная проблема была зарегистрирована, и, судя по написанному, исправления для 4 фреймворка нет и не будет. Скорее всего, у большинства данная проблема может никогда не возникнуть т.к. чтобы она произошла нужно большое стечение обстоятельств.

Но, если проблема все таки произошла, то ответ на этот вопрос неоднозначен и подходов может быть несколько. Например, пробегаться изначально по всем методам всех типов всех сборок и т.п., чтобы они были в списке в нужном порядке, хоть это и довольно странно.

Для себя мы решили, на текущий момент, сделать небольшую утилиту в виде MSBuild task, которую мы добавили в процесс билда. Эта утилита анализирует сборки, используя Mono.Cecil, чтобы иметь возможность удобным образом просматривать инструкции методов. В процессе анализа, утилита ищет определенные последовательность инструкций, изучая их операнды, получает тип и название метода, который будет искать RuntimeBinder и проверяет не может ли произойти описанная проблема. В случае, если такой проблемный вызов будет найден, то при билде проекта появится ошибка.

Другими словами, избежать ошибок вообще можно только установив пользователям версию фреймворка 4.5. Если же это невозможно (как в нашем случае), придётся либо не пользоваться dynamic, либо пользоваться с осторожностью.

Тестовый пример

Чтобы увидеть ошибку, это код надо запустить на компьютере с установленным .NET Framework версии 4.0.
Для контраста, на компьютерах с фремворком версией выше все отработает, как надо.

Assembly A:
A.cs

public class A
{
public void MethodForTokenOffset() {}

public event EventHandler Event
{
add { Console.WriteLine("Event, add");}
remove {}
}

public object this[long id]
{
get
{
Console.WriteLine("Indexator, get {0}", id);
return new { Name = "ThisIsSomeObject" };
}
set { Console.WriteLine("Indexator, set {0}", id); }
}
}

AssemblyB
Program.cs

class Program
{
static void Main()
{
typeof(B).GetEvents();
typeof(B).GetProperties();
new B();
Console.ReadLine();
}
}

B.cs

public class B : A
{
public B()
{
try
{
dynamic obj = new { Handler = new EventHandler((s, e) => Console.WriteLine("EventHandler")), Id = 1L };
MethodForEvent(obj.Handler);
var result = MethodForIndexator(obj.Id);
Console.WriteLine("Method result, {0}", result);
MethodForProperty(obj.Id);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}

public void MethodForEvent(EventHandler handler)
{
Console.WriteLine("MethodForEvent, {0}", handler);
}

public void StubMethodForOffset()
{
}

public long MethodForIndexator(long id)
{
Console.WriteLine("MethodForIndexator, {0}", id);
return 0;
}

public void MethodForProperty(long id)
{
Console.WriteLine("MethodForProperty, {0}", id);
}
}

Автор: ApInvent

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js