Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/OneScript.Core/Execution/IBslProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This Source Code Form is subject to the terms of the

#nullable enable

using System;
using OneScript.Contexts;
using OneScript.DependencyInjection;
using OneScript.Values;
Expand All @@ -15,7 +16,10 @@ This Source Code Form is subject to the terms of the
namespace OneScript.Execution
{
/// <summary>
/// Готовый к исполнению bsl-процесс, с настроенным окружением
/// Готовый к исполнению bsl-процесс, с настроенным окружением.
///
/// Процесс освобождается тем, кто его создал, когда единица исполнения отработала.
/// Освобождение завершает поток исполнения процесса.
/// </summary>
public interface IBslProcess
{
Expand All @@ -33,7 +37,18 @@ public interface IBslProcess
/// Сервисы текущего процесса
/// </summary>
public IServiceContainer Services { get; }

public int VirtualThreadId { get; }

/**
* Обертка текущего процесса в терминах BSL.
* Может отсутствовать, полностью определяется хост-приложением процесса.
*/
public IRuntimeContextInstance? BslWrapper
{
get => null;
// ReSharper disable once ValueParameterNotUsed
set { }
}
}
}
133 changes: 133 additions & 0 deletions src/OneScript.StandardLibrary/ExecutionThreadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*----------------------------------------------------------
This Source Code Form is subject to the terms of the
Mozilla Public License, v.2.0. If a copy of the MPL
was not distributed with this file, You can obtain one
at http://mozilla.org/MPL/2.0/.
----------------------------------------------------------*/

using System;
using System.Linq;
using OneScript.Contexts;
using OneScript.Execution;
using OneScript.StandardLibrary.Collections;
using ScriptEngine;
using ScriptEngine.Machine;
using ScriptEngine.Machine.Contexts;

namespace OneScript.StandardLibrary
{
/// <summary>
/// Поток исполнения bsl-кода.
///
/// Потоком исполнения является каждая независимая единица исполнения: основной скрипт,
/// каждое фоновое задание и каждый обрабатываемый запрос веб-сервера. Получить поток
/// исполнения текущего кода можно функцией ТекущийПоток().
///
/// Свойство Данные представляет собой хранилище, живущее ровно столько же, сколько живёт
/// сам поток исполнения. Оно предназначено для библиотек, которым нужно хранить состояние
/// в разрезе единицы исполнения (аналог thread-local хранилища).
///
/// По завершении потока исполнения поднимается событие ПриЗавершении (оно же OnTermination),
/// на которое можно подписаться штатным способом:
///
/// ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ВернутьСоединениеВПул;
///
/// Обработчик вызывается до очистки данных потока, поэтому ещё видит их содержимое.
/// Это позволяет владельцам ресурсов узнавать о конце единицы исполнения вместо того,
/// чтобы опрашивать список фоновых заданий.
/// </summary>
[ContextClass("ПотокИсполнения", "ExecutionThread")]
public sealed class ExecutionThreadContext : AutoContext<ExecutionThreadContext>, IDisposable
{
private readonly IBslProcess _process;

private bool _isDisposed;

private ExecutionThreadContext(IBslProcess process)
{
_process = process;
}

/// <summary>
/// Идентификатор потока исполнения. Предназначен для диагностики и журналирования.
///
/// Идентификаторы выдаются последовательно и в пределах запущенного движка не повторяются,
/// пока счётчик не исчерпает диапазон Int32. Для хранения состояния в разрезе потока
/// исполнения используйте свойство Данные, а не идентификатор в качестве ключа.
/// </summary>
/// <value>Число. Идентификатор потока исполнения.</value>
[ContextProperty("Идентификатор", "Id")]
public int Identifier => _process.VirtualThreadId;

/// <summary>
/// Хранилище данных потока исполнения, аналог набора thread-local переменных.
///
/// Соответствие создаётся вместе с потоком исполнения и не разделяется с другими потоками.
/// В конце потока исполнения соответствие очищается, а его значения, поддерживающие
/// интерфейс IDisposable среды CLR, принудительно освобождаются.
/// </summary>
/// <value>Соответствие. Данные потока исполнения.</value>
[ContextProperty("Данные", "Data")]
public MapImpl Data { get; } = new MapImpl();

/// <summary>
/// Возвращает поток исполнения указанного bsl-процесса, создавая его при первом обращении.
/// Для одного процесса всегда возвращается один и тот же экземпляр.
///
/// Созданный поток остаётся на процессе и освобождается вместе с ним.
/// </summary>
internal static ExecutionThreadContext Of(IBslProcess process)
{
if (process.BslWrapper is ExecutionThreadContext wrapper)
return wrapper;

lock (process)
{
if (process.BslWrapper is ExecutionThreadContext created)
return created;

if (process.BslWrapper != null)
throw new InvalidOperationException($"BslWrapper for process is not {nameof(ExecutionThreadContext)}: {process.BslWrapper.GetType()}");

var thread = new ExecutionThreadContext(process);
process.BslWrapper = thread;

return thread;
}
}

public void Dispose()
{
// Пока идёт завершение, поток ещё числится за процессом, и обработчик вправе
// добраться до него через ТекущийПоток(). Если он при этом освободит процесс,
// завершение не должно пойти по второму кругу.
if (_isDisposed)
return;

_isDisposed = true;

try
{
foreach (var item in Data.ToArray())
{
if (item.Value is not IDisposable disposable)
continue;

try
{
disposable.Dispose();
}
catch (Exception exception)
{
SystemLogger.Write(
$"WARNING! Error releasing execution thread data '{item.Key}': {exception.Message}");
}
}
}
finally
{
Data.Clear();
}
}
}
}
22 changes: 22 additions & 0 deletions src/OneScript.StandardLibrary/StandardGlobalContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ public void RunGarbageCollection()
GC.WaitForPendingFinalizers();
}

/// <summary>
/// Возвращает поток исполнения, в котором выполняется текущий код.
///
/// Отдельным потоком исполнения является каждая независимая единица исполнения bsl-кода:
/// основной скрипт, каждое фоновое задание и каждый обрабатываемый запрос веб-сервера.
/// Для одной единицы исполнения всегда возвращается один и тот же объект, поэтому его
/// свойство Данные можно использовать как набор thread-local переменных.
///
/// В отличие от идентификатора фонового задания, поток исполнения определён во всех
/// контекстах, в том числе при обработке запросов веб-сервера, где фоновое задание
/// отсутствует.
///
/// Поток исполнения не наследуется: фоновое задание, запущенное из текущего потока
/// исполнения, получит собственный поток с собственными данными.
/// </summary>
/// <returns>ПотокИсполнения. Поток исполнения текущего кода.</returns>
[ContextMethod("ТекущийПоток", "CurrentThread")]
public ExecutionThreadContext CurrentThread(IBslProcess process)
{
return ExecutionThreadContext.Of(process);
Comment on lines +95 to +97

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Оба места нашли верно, проверил.

ConsoleContext.cs:274 исправлен в eb5a9968 — завершение потока добавлено в finally вокруг вызова обработчика Ctrl+C.

С ExceptionInfoFactory.cs:60 так не выйдет: он живёт в ScriptEngine, а ExecutionThreadContext — в OneScript.StandardLibrary, и ссылка между ними односторонняя (StandardLibrary → ScriptEngine). Позвать завершение оттуда физически нечем.

То есть остаётся ровно та развилка, которую вы и назвали: либо централизовать жизненный цикл на границе процесса, либо оставить это место незакрытым. Вижу два варианта централизации:

  1. Слушатель в OneScript.Core, например IBslProcessLifetime с методом Finished(IBslProcess), регистрируемый как enumerable. StandardLibrary регистрирует реализацию, которая зовёт Release, а владельцы процессов в движке уведомляют слушателей. Абстракция маленькая, но владельцы всё равно должны её звать.
  2. Сделать IBslProcess освобождаемым и завершать его владельцами через using. Честнее по смыслу и закрывает вообще все места разом, но это изменение публичного интерфейса, который реализуют ещё ForbiddenBslProcess и тестовые заглушки.

Сам выбирать не стал: это уже третье изменение формы API в этом PR, и решать, по-моему, автору движка. Пока оставил как есть и фиксирую здесь, чтобы не потерялось.

Замечу для полноты картины: утечки памяти в незакрытых местах нет — поток привязан к процессу через ConditionalWeakTable и исчезает вместе с ним. Теряется только детерминированность: ПриЗавершении не наступает и значения с IDisposable не освобождаются сразу. Для разового преобразования в строку это вряд ли критично, но контракт действительно нарушается.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Закрыто. Из двух вариантов, которые я тут описывал, взяли второй — жизненный цикл централизован на процессе.

IBslProcess стал освобождаемым и сам завершает свой поток исполнения (4ee60355). Владельцы процессов перешли на using, явных вызовов завершения не осталось нигде. Заодно исчезла статическая коллекция, которой процессы сопоставлялись потокам, — на неё отдельно указывал мэйнтейнер.

Оба места, которые вы нашли, теперь закрыты:

  • ConsoleContext.cs:274 — обработчик Ctrl+C исполняется в процессе под using;
  • ExceptionInfoFactory.cs:60 — тоже под using. Раньше сюда было не дотянуться: он живёт в ScriptEngine, а завершение потока — в OneScript.StandardLibrary, и ссылка между ними односторонняя. После централизации ему достаточно освободить процесс, про поток исполнения знать не нужно.

Попутно нашлась и закрыта соседняя утечка: BslProcess создаёт себе область сервисов в конструкторе, но не освобождал её никогда — scoped-регистрации процесса жили до конца работы движка. Теперь освобождает, последним действием: завершение потока обращается к области за процессором событий (12861a55).

Перед этой правкой разобрал TinyIoC, потому что освобождение дочернего контейнера могло задеть родительские объекты: у синглтон-фабрики GetFactoryForChildContainer возвращает this. Оказалось безопасно — родительские фабрики идут транзитом через GetParentObjectFactory и в реестр дочернего контейнера не попадают, а Dispose обходит только его. Зафиксировано тестом ProcessDoesNotReleaseScopeOfAnotherProcess.

Оговорюсь честно: сегодня по этой ветке освобождать нечего — единственная scoped-регистрация в движке StackMachineProvider, и он не IDisposable. Так что правка закрывает утечку на будущее, а тест работает на собственном зонде, а не на живой регистрации.

}

/// <summary>
/// Приостанавливает выполнение скрипта.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*----------------------------------------------------------
/*----------------------------------------------------------
This Source Code Form is subject to the terms of the
Mozilla Public License, v.2.0. If a copy of the MPL
was not distributed with this file, You can obtain one
Expand Down
8 changes: 8 additions & 0 deletions src/ScriptEngine.HostedScript/DefaultEventProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ public void RemoveHandler(
}
}

public void RemoveAllHandlers(IRuntimeContextInstance eventSource)
{
lock (_subscriptionLock)
{
_registeredHandlers.Remove(eventSource);
}
}

public void HandleEvent(IRuntimeContextInstance eventSource, string eventName, IValue[] eventArgs,
IBslProcess process)
{
Expand Down
44 changes: 44 additions & 0 deletions src/ScriptEngine/BslProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ namespace ScriptEngine
{
internal class BslProcess : IBslProcess
{
private static readonly string[] TerminationEventNames = { "ПриЗавершении", "OnTermination" };

private readonly IExecutorProvider[] _executorProviders;
private readonly IDictionary<Type, Invoker> _bslExecutorsByModule;

private bool _isRunning;
private bool _disposed;

public BslProcess(int id, ExecutionContext context, IEnumerable<IExecutorProvider> executorProviders)
{
Expand Down Expand Up @@ -55,10 +58,51 @@ public BslValue Run(BslObjectValue target, IExecutableModule module, BslScriptMe
{
if (notifyExecutors)
{
RaiseTerminationEvent();
if (BslWrapper is IDisposable disposable)
{
disposable.Dispose();
}

Array.ForEach(_executorProviders, e => e.AfterProcessExit(this));
Services.Dispose();
_isRunning = false;
}
}
}

public IRuntimeContextInstance BslWrapper { get; set; }

private void RaiseTerminationEvent()
{
if (BslWrapper == null)
return;

var eventProcessor = Services.TryResolve<IEventProcessor>();
if (eventProcessor == null)
return;

try
{
foreach (var eventName in TerminationEventNames)
{
try
{
eventProcessor.HandleEvent(BslWrapper, eventName, Array.Empty<IValue>(), this);
}
catch (Exception exception)
{
SystemLogger.Write(
$"WARNING! Error in execution thread termination handler '{eventName}': {exception.Message}");
}
}
}
finally
{
// Процессор событий держит источник, пока подписки не сняты. Потоков исполнения
// много и живут они недолго, поэтому без явного снятия реестр рос бы бесконечно.
eventProcessor.RemoveAllHandlers(BslWrapper);
}
}
}
}
12 changes: 12 additions & 0 deletions src/ScriptEngine/Machine/IEventProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,17 @@ void RemoveHandler(
string handlerMethod);

void HandleEvent(IRuntimeContextInstance eventSource, string eventName, IValue[] eventArgs, IBslProcess process);

/// <summary>
/// Снимает все подписки на события указанного источника.
///
/// Нужен источникам, которые живут меньше самого процессора событий: без этого реестр
/// подписок удерживает такой источник до конца работы движка. Реализация по умолчанию
/// ничего не делает, чтобы не ломать сторонние процессоры событий.
/// </summary>
/// <param name="eventSource">Источник, подписки на который нужно снять.</param>
void RemoveAllHandlers(IRuntimeContextInstance eventSource)
{
}
}
}
73 changes: 73 additions & 0 deletions src/Tests/OneScript.Core.Tests/BslProcessTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*----------------------------------------------------------
This Source Code Form is subject to the terms of the
Mozilla Public License, v.2.0. If a copy of the MPL
was not distributed with this file, You can obtain one
at http://mozilla.org/MPL/2.0/.
----------------------------------------------------------*/

using System;
using FluentAssertions;
using ScriptEngine.Hosting;
using Xunit;

namespace OneScript.Core.Tests
{
/// <summary>
/// Регистрируется как scoped, поэтому живёт ровно столько же, сколько область сервисов процесса.
/// </summary>
public sealed class ScopedDisposableProbe : IDisposable
{
public bool IsDisposed { get; private set; }

public void Dispose()
{
IsDisposed = true;
}
}

public class BslProcessTests
{
private static ScriptEngine.ScriptingEngine CreateEngineWithScopedProbe()
{
var builder = DefaultEngineBuilder.Create().SetDefaultOptions();
builder.Services.RegisterScoped<ScopedDisposableProbe>();

var engine = builder.Build();
engine.Initialize();

return engine;
}

[Fact]
public void ProcessReleasesItsServiceScope()
{
var engine = CreateEngineWithScopedProbe();

var process = engine.NewProcess();
var scoped = process.Services.Resolve<ScopedDisposableProbe>();

scoped.IsDisposed.Should().BeFalse("процесс ещё работает");

engine.AttachedScriptsFactory.LoadFromString(
engine.GetCompilerService(), "f = 1", process);

scoped.IsDisposed.Should().BeTrue("процесс владеет своей областью сервисов");
}

[Fact]
public void ProcessDoesNotReleaseScopeOfAnotherProcess()
{
var engine = CreateEngineWithScopedProbe();

var first = engine.NewProcess();
var second = engine.NewProcess();

var firstScoped = first.Services.Resolve<ScopedDisposableProbe>();
var secondScoped = second.Services.Resolve<ScopedDisposableProbe>();

firstScoped.Should().NotBeSameAs(secondScoped, "у каждого процесса своя область сервисов");

secondScoped.IsDisposed.Should().BeFalse("освобождение одного процесса не трогает другой");
}
}
}
Loading