From 6c2da665d3732f40ad9b56302199e0797323cc1a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:31:20 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=98=D0=B4=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=9F=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=D0=98?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Библиотекам, которым нужно хранить данные в разрезе единицы исполнения (аналог thread-local хранилища), до сих пор приходилось использовать ФоновыеЗадания.ПолучитьТекущее(). Внутри обработчика запроса веб-сервера этот способ не работает: фоновое задание там отсутствует, метод возвращает Неопределено, и все одновременно обрабатываемые запросы получают один и тот же ключ, совпадающий с ключом основного потока. Движок уже присваивает каждой единице исполнения уникальный IBslProcess.VirtualThreadId: отдельный процесс создаётся для основного скрипта, для каждого фонового задания и для каждого запроса веб-сервера. Значение просто не было доступно из BSL. Функция возвращает этот идентификатор, получая процесс через штатную инъекцию IBslProcess первым параметром контекстного метода, поэтому работает и в стековой машине, и в нативном компиляторе. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2E2kuK7qYqo2tNr8agbM7 --- .../StandardGlobalContext.cs | 22 +++++++++++++ tests/tasks.os | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/OneScript.StandardLibrary/StandardGlobalContext.cs b/src/OneScript.StandardLibrary/StandardGlobalContext.cs index f12754f12..23a9f2897 100644 --- a/src/OneScript.StandardLibrary/StandardGlobalContext.cs +++ b/src/OneScript.StandardLibrary/StandardGlobalContext.cs @@ -75,6 +75,28 @@ public void RunGarbageCollection() GC.WaitForPendingFinalizers(); } + /// + /// Возвращает идентификатор потока исполнения, в котором выполняется текущий код. + /// + /// Каждая независимая единица исполнения bsl-кода получает собственный идентификатор: + /// основной скрипт, каждое фоновое задание и каждый обрабатываемый запрос веб-сервера. + /// Идентификаторы уникальны в пределах запущенного движка и не переиспользуются. + /// + /// Метод предназначен для библиотек, которым нужно хранить данные в разрезе единицы + /// исполнения (аналог thread-local хранилища). В отличие от идентификатора фонового задания, + /// значение определено во всех контекстах, в том числе при обработке запросов веб-сервера, + /// где фоновое задание отсутствует. + /// + /// Идентификатор не наследуется: фоновое задание, запущенное из текущего потока исполнения, + /// получит собственное значение. + /// + /// Число. Идентификатор текущего потока исполнения. + [ContextMethod("ИдентификаторПотокаИсполнения", "ExecutionThreadId")] + public int ExecutionThreadId(IBslProcess process) + { + return process.VirtualThreadId; + } + /// /// Приостанавливает выполнение скрипта. /// diff --git a/tests/tasks.os b/tests/tasks.os index 99f79d9b2..cb5252961 100644 --- a/tests/tasks.os +++ b/tests/tasks.os @@ -24,6 +24,7 @@ ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоВозвращаетсяРезультатДелегата"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоРаботаетБлокировка"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоКодМожетОпределитьИДЗадания"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьУникальностьИдентификатораПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьТекущее"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьФоновыеЗадания"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПоискТекущегоСредиМножестваЗавершенных"); @@ -276,6 +277,38 @@ КонецПроцедуры +Функция ВернутьИдентификаторПотокаИсполнения() Экспорт + + Приостановить(500); + Возврат ИдентификаторПотокаИсполнения(); + +КонецФункции + +Процедура ТестДолжен_ПроверитьУникальностьИдентификатораПотокаИсполнения() Экспорт + + ИдОсновногоПотока = ИдентификаторПотокаИсполнения(); + юТест.ПроверитьРавенство(ИдОсновногоПотока, ИдентификаторПотокаИсполнения(), + "Идентификатор потока исполнения должен быть одинаковым при повторном вызове"); + + МассивЗаданий = Новый Массив; + Для Сч = 1 По 4 Цикл + МассивЗаданий.Добавить(ФоновыеЗадания.Выполнить(ЭтотОбъект, "ВернутьИдентификаторПотокаИсполнения")); + КонецЦикла; + + ФоновыеЗадания.ОжидатьВсе(МассивЗаданий); + + УникальныеИдентификаторы = Новый Соответствие; + Для Каждого Задание Из МассивЗаданий Цикл + юТест.ПроверитьНеРавенство(ИдОсновногоПотока, Задание.Результат, + "Фоновое задание должно получить собственный идентификатор потока исполнения"); + УникальныеИдентификаторы.Вставить(Задание.Результат, Истина); + КонецЦикла; + + юТест.ПроверитьРавенство(МассивЗаданий.Количество(), УникальныеИдентификаторы.Количество(), + "Идентификаторы потоков исполнения одновременных фоновых заданий должны различаться"); + +КонецПроцедуры + Процедура ТестДолжен_ПроверитьЧтоВИнформацииОбОшибкеЕстьСтекВызовов() Экспорт Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПроцедураСИсключением"); From 3b5d11649f3c02a6be4ba991cb4a588f2d5bcac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:39:08 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=B5=D0=B1-=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B5=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UseBslExceptionHandler создавал собственный bsl-процесс вместо того, чтобы взять процесс запроса из HttpContext.Items, куда его кладёт middleware конвейера. Из-за этого обработчик исключений выполнялся в другой единице исполнения, чем упавший обработчик запроса, и не видел её ИдентификаторПотокаИсполнения. Процесс запроса теперь берётся из HttpContext.Items, а собственный создаётся только если исключение возникло раньше, чем процесс запроса (например, в middleware статических файлов). Получение процесса вынесено в GetOrCreateProcess и стало идемпотентным. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2E2kuK7qYqo2tNr8agbM7 --- src/OneScript.Web.Server/WebServer.cs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/OneScript.Web.Server/WebServer.cs b/src/OneScript.Web.Server/WebServer.cs index 0dbe15af5..05c9bf770 100644 --- a/src/OneScript.Web.Server/WebServer.cs +++ b/src/OneScript.Web.Server/WebServer.cs @@ -123,8 +123,7 @@ private void ConfigureApp() _app.Use((context, next) => { - var process = _executionContext.Services.Resolve().NewProcess(); - context.Items.Add(typeof(IBslProcess), process); + GetOrCreateProcess(context); return next(); }); @@ -178,7 +177,11 @@ private void UseBslExceptionHandler() var methodNumber = _exceptionHandler?.Target.GetMethodNumber(_exceptionHandler?.MethodName) ?? throw new InvalidOperationException(); - var process = _executionContext.Services.Resolve().NewProcess(); + // Обработчик исключений работает в том же процессе, что и обработчик запроса, + // поэтому видит контекст исполнения, в котором возникла ошибка. + // Собственный процесс создаётся только если исключение возникло до того, + // как процесс запроса был создан (например, в middleware статических файлов). + var process = GetOrCreateProcess(context); try { @@ -197,6 +200,22 @@ private void UseBslExceptionHandler() }); } + /// + /// Возвращает bsl-процесс, обслуживающий текущий запрос, создавая его при первом обращении. + /// Один запрос всегда обслуживается одним процессом, поэтому весь bsl-код запроса + /// видит один и тот же ИдентификаторПотокаИсполнения. + /// + private IBslProcess GetOrCreateProcess(HttpContext context) + { + if (context.Items.TryGetValue(typeof(IBslProcess), out var stored) && stored is IBslProcess existing) + return existing; + + var process = _executionContext.Services.Resolve().NewProcess(); + context.Items[typeof(IBslProcess)] = process; + + return process; + } + private static void WriteExceptionToResponse(HttpContext httpContext, Exception ex) { httpContext.Response.StatusCode = 500; From 3cb707cb4fc0664b3feaf9f61b1008b9b3278be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:04:59 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=81=D1=8F=20=D0=B2=20HttpContext.Fea?= =?UTF-8?q?tures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По итогам ревью PR #1725. HttpContext.Items доступны из bsl как Контекст.Данные — это живая обёртка над тем же словарём (HttpContextWrapper.Data), поэтому обработчик запроса мог удалить оттуда процесс, которым сам же и исполняется. После Контекст.Данные.Очистить() обработчик исключений не находил процесс запроса и создавал новый, то есть работал в другой единице исполнения. Features из bsl не видны, что снимает проблему в корне. Заодно цикл middleware получает процесс через GetOrCreateProcess вместо сырого приведения значения из Items. Исправлен комментарий про запасное создание процесса: UseStaticFiles регистрируется раньше UseExceptionHandler, поэтому исключение из middleware статических файлов до обработчика не доходит. Между UseExceptionHandler и middleware процесса стоит UseWebSockets — вот оттуда исключение придёт при ещё не созданном процессе. Уточнена документация ИдентификаторПотокаИсполнения: счётчик процессов имеет тип Int32 и после исчерпания диапазона начинает выдавать значения заново, поэтому обещание "не переиспользуются" заменено описанием реальной границы и требованием освобождать записи завершившихся единиц исполнения. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2E2kuK7qYqo2tNr8agbM7 --- .../StandardGlobalContext.cs | 6 +++++- src/OneScript.Web.Server/WebServer.cs | 16 +++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/OneScript.StandardLibrary/StandardGlobalContext.cs b/src/OneScript.StandardLibrary/StandardGlobalContext.cs index 23a9f2897..392f91746 100644 --- a/src/OneScript.StandardLibrary/StandardGlobalContext.cs +++ b/src/OneScript.StandardLibrary/StandardGlobalContext.cs @@ -80,7 +80,11 @@ public void RunGarbageCollection() /// /// Каждая независимая единица исполнения bsl-кода получает собственный идентификатор: /// основной скрипт, каждое фоновое задание и каждый обрабатываемый запрос веб-сервера. - /// Идентификаторы уникальны в пределах запущенного движка и не переиспользуются. + /// Идентификаторы выдаются последовательно, начиная с единицы, и в пределах запущенного + /// движка не повторяются, пока счётчик не исчерпает диапазон Int32. После этого значения + /// начинают выдаваться заново и могут совпасть с идентификатором долгоживущей единицы + /// исполнения, поэтому хранилища, живущие всё время работы движка, должны освобождать + /// записи завершившихся единиц исполнения, а не накапливать их. /// /// Метод предназначен для библиотек, которым нужно хранить данные в разрезе единицы /// исполнения (аналог thread-local хранилища). В отличие от идентификатора фонового задания, diff --git a/src/OneScript.Web.Server/WebServer.cs b/src/OneScript.Web.Server/WebServer.cs index 05c9bf770..fff0fd461 100644 --- a/src/OneScript.Web.Server/WebServer.cs +++ b/src/OneScript.Web.Server/WebServer.cs @@ -137,7 +137,7 @@ private void ConfigureApp() new RequestDelegateWrapper(next) }; - var process = (IBslProcess)context.Items[typeof(IBslProcess)]; + var process = GetOrCreateProcess(context); var methodNumber = middleware.Target.GetMethodNumber(middleware.MethodName); middleware.Target.CallAsProcedure(methodNumber, args, process); @@ -179,8 +179,9 @@ private void UseBslExceptionHandler() // Обработчик исключений работает в том же процессе, что и обработчик запроса, // поэтому видит контекст исполнения, в котором возникла ошибка. - // Собственный процесс создаётся только если исключение возникло до того, - // как процесс запроса был создан (например, в middleware статических файлов). + // Собственный процесс создаётся только если исключение возникло раньше, чем + // процесс запроса: между UseExceptionHandler и middleware процесса стоит + // UseWebSockets. var process = GetOrCreateProcess(context); try @@ -204,14 +205,19 @@ private void UseBslExceptionHandler() /// Возвращает bsl-процесс, обслуживающий текущий запрос, создавая его при первом обращении. /// Один запрос всегда обслуживается одним процессом, поэтому весь bsl-код запроса /// видит один и тот же ИдентификаторПотокаИсполнения. + /// + /// Процесс хранится в HttpContext.Features, а не в HttpContext.Items: Items доступны + /// из bsl-кода как Контекст.Данные, и обработчик запроса мог бы удалить оттуда процесс, + /// которым сам же и исполняется. /// private IBslProcess GetOrCreateProcess(HttpContext context) { - if (context.Items.TryGetValue(typeof(IBslProcess), out var stored) && stored is IBslProcess existing) + var existing = context.Features.Get(); + if (existing != null) return existing; var process = _executionContext.Services.Resolve().NewProcess(); - context.Items[typeof(IBslProcess)] = process; + context.Features.Set(process); return process; } From d633efeb83abd5caf6b81cb964c254c0d9dc8512 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:19:32 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20scoped-=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По замечанию мэйнтейнера в PR #1725: словарь запроса должен остаться полностью прикладным, а получением или созданием процесса должен заниматься scoped-сервис. RequestBslProcess регистрируется в контейнере веб-приложения как scoped и создаёт процесс при первом обращении. Область сервисов запроса живёт ровно столько же, сколько запрос, и переиспользуется UseExceptionHandler, поэтому обработчик исключений получает тот же процесс, что и упавший обработчик запроса. HttpContext.Items и HttpContext.Features для передачи процесса больше не используются. Middleware, создававшее процесс заранее, удалено: запросы, не дошедшие до bsl-кода, процесс теперь не создают. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2E2kuK7qYqo2tNr8agbM7 --- src/OneScript.Web.Server/RequestBslProcess.cs | 48 +++++++++++++++++++ src/OneScript.Web.Server/WebServer.cs | 40 +++++----------- 2 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 src/OneScript.Web.Server/RequestBslProcess.cs diff --git a/src/OneScript.Web.Server/RequestBslProcess.cs b/src/OneScript.Web.Server/RequestBslProcess.cs new file mode 100644 index 000000000..ff6367950 --- /dev/null +++ b/src/OneScript.Web.Server/RequestBslProcess.cs @@ -0,0 +1,48 @@ +/*---------------------------------------------------------- +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 OneScript.Execution; + +namespace OneScript.Web.Server +{ + /// + /// Scoped-сервис, хранящий bsl-процесс, который обслуживает текущий запрос. + /// + /// Один запрос всегда обслуживается одним процессом, поэтому весь bsl-код запроса, + /// включая обработчик исключений, видит один и тот же ИдентификаторПотокаИсполнения. + /// Процесс создаётся при первом обращении: запросы, не дошедшие до bsl-кода, + /// процесс не создают. + /// + /// Процесс намеренно не хранится в HttpContext.Items: Items доступны из bsl-кода + /// как Контекст.Данные и остаются полностью прикладными. + /// + internal sealed class RequestBslProcess + { + private readonly IBslProcessFactory _processFactory; + private readonly object _lock = new object(); + + private IBslProcess _process; + + public RequestBslProcess(IBslProcessFactory processFactory) + { + _processFactory = processFactory; + } + + public IBslProcess Process + { + get + { + if (_process != null) + return _process; + + lock (_lock) + { + return _process ??= _processFactory.NewProcess(); + } + } + } + } +} diff --git a/src/OneScript.Web.Server/WebServer.cs b/src/OneScript.Web.Server/WebServer.cs index fff0fd461..8ee683214 100644 --- a/src/OneScript.Web.Server/WebServer.cs +++ b/src/OneScript.Web.Server/WebServer.cs @@ -104,6 +104,9 @@ private void ConfigureApp() builder.Services.Configure(builder.Configuration.GetSection("FormOptions")); + builder.Services.AddScoped(_ => + new RequestBslProcess(_executionContext.Services.Resolve())); + _app = builder.Build(); if (_useStaticFiles) @@ -121,12 +124,6 @@ private void ConfigureApp() if (_useWebSockets) _app.UseWebSockets(); - _app.Use((context, next) => - { - GetOrCreateProcess(context); - return next(); - }); - _middlewares.ForEach(middleware => { _app.Use((context, next) => @@ -137,7 +134,7 @@ private void ConfigureApp() new RequestDelegateWrapper(next) }; - var process = GetOrCreateProcess(context); + var process = GetRequestProcess(context); var methodNumber = middleware.Target.GetMethodNumber(middleware.MethodName); middleware.Target.CallAsProcedure(methodNumber, args, process); @@ -177,12 +174,10 @@ private void UseBslExceptionHandler() var methodNumber = _exceptionHandler?.Target.GetMethodNumber(_exceptionHandler?.MethodName) ?? throw new InvalidOperationException(); - // Обработчик исключений работает в том же процессе, что и обработчик запроса, - // поэтому видит контекст исполнения, в котором возникла ошибка. - // Собственный процесс создаётся только если исключение возникло раньше, чем - // процесс запроса: между UseExceptionHandler и middleware процесса стоит - // UseWebSockets. - var process = GetOrCreateProcess(context); + // UseExceptionHandler переиспользует область сервисов запроса, поэтому + // обработчик исключений получает тот же процесс, что и упавший обработчик + // запроса, и видит контекст исполнения, в котором возникла ошибка. + var process = GetRequestProcess(context); try { @@ -202,24 +197,11 @@ private void UseBslExceptionHandler() } /// - /// Возвращает bsl-процесс, обслуживающий текущий запрос, создавая его при первом обращении. - /// Один запрос всегда обслуживается одним процессом, поэтому весь bsl-код запроса - /// видит один и тот же ИдентификаторПотокаИсполнения. - /// - /// Процесс хранится в HttpContext.Features, а не в HttpContext.Items: Items доступны - /// из bsl-кода как Контекст.Данные, и обработчик запроса мог бы удалить оттуда процесс, - /// которым сам же и исполняется. + /// Возвращает bsl-процесс, обслуживающий текущий запрос. /// - private IBslProcess GetOrCreateProcess(HttpContext context) + private static IBslProcess GetRequestProcess(HttpContext context) { - var existing = context.Features.Get(); - if (existing != null) - return existing; - - var process = _executionContext.Services.Resolve().NewProcess(); - context.Features.Set(process); - - return process; + return context.RequestServices.GetRequiredService().Process; } private static void WriteExceptionToResponse(HttpContext httpContext, Exception ex) From e9cadbc178f565610b640e6588bcbbcacc931597 Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Tue, 25 Aug 2026 10:09:24 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=D0=A2=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=D0=9F=D0=BE=D1=82=D0=BE=D0=BA()=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=98=D0=B4=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=9F=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=98=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По предложению мэйнтейнера в PR #1725: вместо идентификатора процесса отдавать наружу сам поток исполнения, у которого есть соответствие, работающее как набор thread-local переменных и принудительно освобождаемое вместе со всеми элементами в конце процесса. Добавлен класс ПотокИсполнения со свойствами Идентификатор и Данные. Экземпляр привязан к bsl-процессу через ConditionalWeakTable, поэтому ТекущийПоток() в пределах одной единицы исполнения всегда возвращает один и тот же объект, а запись исчезает вместе с процессом. Владельцы процесса завершают поток исполнения явно: менеджер фоновых заданий по завершении задания, веб-сервер - по окончании обработки запроса, через Dispose scoped-сервиса RequestBslProcess. При завершении соответствие очищается, а значения, поддерживающие IDisposable, освобождаются. Такой подход снимает и замечание про переполнение счётчика: ключом для хранения состояния служит сам объект потока, а не число. Глобальная функция ИдентификаторПотокаИсполнения() удалена, идентификатор доступен как ТекущийПоток().Идентификатор. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014ZfXJeyPJxZxtooYQzTt7s --- .../StandardGlobalContext.cs | 31 +++--- .../Tasks/BackgroundTasksManager.cs | 13 ++- .../Threads/ExecutionThreadContext.cs | 98 +++++++++++++++++++ src/OneScript.Web.Server/RequestBslProcess.cs | 15 ++- tests/tasks.os | 96 ++++++++++++++++-- 5 files changed, 225 insertions(+), 28 deletions(-) create mode 100644 src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs diff --git a/src/OneScript.StandardLibrary/StandardGlobalContext.cs b/src/OneScript.StandardLibrary/StandardGlobalContext.cs index 392f91746..883ec1a00 100644 --- a/src/OneScript.StandardLibrary/StandardGlobalContext.cs +++ b/src/OneScript.StandardLibrary/StandardGlobalContext.cs @@ -12,6 +12,7 @@ This Source Code Form is subject to the terms of the using OneScript.Contexts; using OneScript.Exceptions; using OneScript.Execution; +using OneScript.StandardLibrary.Threads; using OneScript.Types; using OneScript.Values; using ScriptEngine.Machine; @@ -76,29 +77,25 @@ public void RunGarbageCollection() } /// - /// Возвращает идентификатор потока исполнения, в котором выполняется текущий код. + /// Возвращает поток исполнения, в котором выполняется текущий код. /// - /// Каждая независимая единица исполнения bsl-кода получает собственный идентификатор: + /// Отдельным потоком исполнения является каждая независимая единица исполнения bsl-кода: /// основной скрипт, каждое фоновое задание и каждый обрабатываемый запрос веб-сервера. - /// Идентификаторы выдаются последовательно, начиная с единицы, и в пределах запущенного - /// движка не повторяются, пока счётчик не исчерпает диапазон Int32. После этого значения - /// начинают выдаваться заново и могут совпасть с идентификатором долгоживущей единицы - /// исполнения, поэтому хранилища, живущие всё время работы движка, должны освобождать - /// записи завершившихся единиц исполнения, а не накапливать их. + /// Для одной единицы исполнения всегда возвращается один и тот же объект, поэтому его + /// свойство Данные можно использовать как набор thread-local переменных. /// - /// Метод предназначен для библиотек, которым нужно хранить данные в разрезе единицы - /// исполнения (аналог thread-local хранилища). В отличие от идентификатора фонового задания, - /// значение определено во всех контекстах, в том числе при обработке запросов веб-сервера, - /// где фоновое задание отсутствует. + /// В отличие от идентификатора фонового задания, поток исполнения определён во всех + /// контекстах, в том числе при обработке запросов веб-сервера, где фоновое задание + /// отсутствует. /// - /// Идентификатор не наследуется: фоновое задание, запущенное из текущего потока исполнения, - /// получит собственное значение. + /// Поток исполнения не наследуется: фоновое задание, запущенное из текущего потока + /// исполнения, получит собственный поток с собственными данными. /// - /// Число. Идентификатор текущего потока исполнения. - [ContextMethod("ИдентификаторПотокаИсполнения", "ExecutionThreadId")] - public int ExecutionThreadId(IBslProcess process) + /// ПотокИсполнения. Поток исполнения текущего кода. + [ContextMethod("ТекущийПоток", "CurrentThread")] + public ExecutionThreadContext CurrentThread(IBslProcess process) { - return process.VirtualThreadId; + return ExecutionThreadContext.Of(process); } /// diff --git a/src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs b/src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs index bb0367539..8bad8516a 100644 --- a/src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs +++ b/src/OneScript.StandardLibrary/Tasks/BackgroundTasksManager.cs @@ -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 @@ -16,6 +16,7 @@ This Source Code Form is subject to the terms of the using OneScript.Exceptions; using OneScript.Execution; using OneScript.StandardLibrary.Collections; +using OneScript.StandardLibrary.Threads; using OneScript.Types; using OneScript.Values; using ScriptEngine.Machine; @@ -52,7 +53,15 @@ public BackgroundTask Execute(IRuntimeContextInstance target, string methodName, var worker = new Task(() => { var process = _runtimeContext.Services.Resolve().NewProcess(); - task.ExecuteOnCurrentThread(process); + try + { + task.ExecuteOnCurrentThread(process); + } + finally + { + // Задание отработало, поток исполнения закончился - освобождаем его данные + ExecutionThreadContext.Release(process); + } }, taskCreationOptions); diff --git a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs new file mode 100644 index 000000000..311047ecf --- /dev/null +++ b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs @@ -0,0 +1,98 @@ +/*---------------------------------------------------------- +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.Runtime.CompilerServices; +using OneScript.Contexts; +using OneScript.Execution; +using OneScript.StandardLibrary.Collections; +using ScriptEngine.Machine.Contexts; + +namespace OneScript.StandardLibrary.Threads +{ + /// + /// Поток исполнения bsl-кода. + /// + /// Потоком исполнения является каждая независимая единица исполнения: основной скрипт, + /// каждое фоновое задание и каждый обрабатываемый запрос веб-сервера. Получить поток + /// исполнения текущего кода можно функцией ТекущийПоток(). + /// + /// Свойство Данные представляет собой хранилище, живущее ровно столько же, сколько живёт + /// сам поток исполнения. Оно предназначено для библиотек, которым нужно хранить состояние + /// в разрезе единицы исполнения (аналог thread-local хранилища). + /// + [ContextClass("ПотокИсполнения", "ExecutionThread")] + public sealed class ExecutionThreadContext : AutoContext, IDisposable + { + private static readonly ConditionalWeakTable Threads = new(); + + private ExecutionThreadContext(IBslProcess process) + { + Identifier = process.VirtualThreadId; + } + + /// + /// Идентификатор потока исполнения. Предназначен для диагностики и журналирования. + /// + /// Идентификаторы выдаются последовательно и в пределах запущенного движка не повторяются, + /// пока счётчик не исчерпает диапазон Int32. Для хранения состояния в разрезе потока + /// исполнения используйте свойство Данные, а не идентификатор в качестве ключа. + /// + /// Число. Идентификатор потока исполнения. + [ContextProperty("Идентификатор", "Id", CanWrite = false)] + public int Identifier { get; } + + /// + /// Хранилище данных потока исполнения, аналог набора thread-local переменных. + /// + /// Соответствие создаётся вместе с потоком исполнения и не разделяется с другими потоками. + /// В конце потока исполнения соответствие очищается, а его значения, поддерживающие + /// интерфейс IDisposable среды CLR, принудительно освобождаются. + /// + /// Соответствие. Данные потока исполнения. + [ContextProperty("Данные", "Data", CanWrite = false)] + public MapImpl Data { get; } = new MapImpl(); + + /// + /// Возвращает поток исполнения указанного bsl-процесса, создавая его при первом обращении. + /// Для одного процесса всегда возвращается один и тот же экземпляр. + /// + internal static ExecutionThreadContext Of(IBslProcess process) + { + return Threads.GetValue(process, p => new ExecutionThreadContext(p)); + } + + /// + /// Завершает поток исполнения процесса, освобождая его данные. + /// + /// Вызывается владельцем процесса, когда процесс отработал: менеджером фоновых заданий + /// по завершении задания и веб-сервером по окончании обработки запроса. Если поток + /// исполнения не создавался, метод ничего не делает. + /// + public static void Release(IBslProcess process) + { + if (process == null) + return; + + if (!Threads.TryGetValue(process, out var thread)) + return; + + Threads.Remove(process); + thread.Dispose(); + } + + public void Dispose() + { + foreach (var item in Data) + { + (item.Value as IDisposable)?.Dispose(); + } + + Data.Clear(); + } + } +} diff --git a/src/OneScript.Web.Server/RequestBslProcess.cs b/src/OneScript.Web.Server/RequestBslProcess.cs index ff6367950..35d364a03 100644 --- a/src/OneScript.Web.Server/RequestBslProcess.cs +++ b/src/OneScript.Web.Server/RequestBslProcess.cs @@ -1,10 +1,12 @@ -/*---------------------------------------------------------- +/*---------------------------------------------------------- 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 OneScript.Execution; +using OneScript.StandardLibrary.Threads; namespace OneScript.Web.Server { @@ -18,8 +20,11 @@ namespace OneScript.Web.Server /// /// Процесс намеренно не хранится в HttpContext.Items: Items доступны из bsl-кода /// как Контекст.Данные и остаются полностью прикладными. + /// + /// Область сервисов запроса освобождается вместе с запросом, поэтому здесь же + /// заканчивается поток исполнения запроса и освобождаются его данные. /// - internal sealed class RequestBslProcess + internal sealed class RequestBslProcess : IDisposable { private readonly IBslProcessFactory _processFactory; private readonly object _lock = new object(); @@ -44,5 +49,11 @@ public IBslProcess Process } } } + + public void Dispose() + { + // Процесс создаётся лениво, поэтому ради освобождения его создавать не нужно + ExecutionThreadContext.Release(_process); + } } } diff --git a/tests/tasks.os b/tests/tasks.os index cb5252961..a32acb377 100644 --- a/tests/tasks.os +++ b/tests/tasks.os @@ -24,7 +24,9 @@ ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоВозвращаетсяРезультатДелегата"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоРаботаетБлокировка"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоКодМожетОпределитьИДЗадания"); - ВсеТесты.Добавить("ТестДолжен_ПроверитьУникальностьИдентификатораПотокаИсполнения"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьУникальностьПотокаИсполнения"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьИзоляциюДанныхПотокаИсполнения"); + ВсеТесты.Добавить("ТестДолжен_ПроверитьОсвобождениеДанныхПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьТекущее"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьФоновыеЗадания"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПоискТекущегоСредиМножестваЗавершенных"); @@ -280,14 +282,14 @@ Функция ВернутьИдентификаторПотокаИсполнения() Экспорт Приостановить(500); - Возврат ИдентификаторПотокаИсполнения(); + Возврат ТекущийПоток().Идентификатор; КонецФункции -Процедура ТестДолжен_ПроверитьУникальностьИдентификатораПотокаИсполнения() Экспорт +Процедура ТестДолжен_ПроверитьУникальностьПотокаИсполнения() Экспорт - ИдОсновногоПотока = ИдентификаторПотокаИсполнения(); - юТест.ПроверитьРавенство(ИдОсновногоПотока, ИдентификаторПотокаИсполнения(), + ИдОсновногоПотока = ТекущийПоток().Идентификатор; + юТест.ПроверитьРавенство(ИдОсновногоПотока, ТекущийПоток().Идентификатор, "Идентификатор потока исполнения должен быть одинаковым при повторном вызове"); МассивЗаданий = Новый Массив; @@ -300,12 +302,92 @@ УникальныеИдентификаторы = Новый Соответствие; Для Каждого Задание Из МассивЗаданий Цикл юТест.ПроверитьНеРавенство(ИдОсновногоПотока, Задание.Результат, - "Фоновое задание должно получить собственный идентификатор потока исполнения"); + "Фоновое задание должно получить собственный поток исполнения"); УникальныеИдентификаторы.Вставить(Задание.Результат, Истина); КонецЦикла; юТест.ПроверитьРавенство(МассивЗаданий.Количество(), УникальныеИдентификаторы.Количество(), - "Идентификаторы потоков исполнения одновременных фоновых заданий должны различаться"); + "Идентификаторы одновременных фоновых заданий должны различаться"); + +КонецПроцедуры + +Функция ЗаписатьВДанныеПотокаИЗабрать(Знач Метка) Экспорт + + ТекущийПоток().Данные.Вставить("метка", Метка); + Приостановить(500); + + // Данные основного потока не должны быть видны, а соседние задания не должны затирать наши + Результат = Новый Структура("Метка, ЕстьЧужое"); + Результат.Метка = ТекущийПоток().Данные.Получить("метка"); + Результат.ЕстьЧужое = (ТекущийПоток().Данные.Получить("изОсновногоПотока") <> Неопределено); + + Возврат Результат; + +КонецФункции + +Процедура ТестДолжен_ПроверитьИзоляциюДанныхПотокаИсполнения() Экспорт + + ТекущийПоток().Данные.Вставить("изОсновногоПотока", Истина); + + юТест.ПроверитьРавенство(Истина, ТекущийПоток().Данные.Получить("изОсновногоПотока"), + "ТекущийПоток() должен возвращать один и тот же экземпляр в пределах потока исполнения"); + + МассивЗаданий = Новый Массив; + Для Сч = 1 По 4 Цикл + Параметры = Новый Массив; + Параметры.Добавить(Сч); + МассивЗаданий.Добавить(ФоновыеЗадания.Выполнить(ЭтотОбъект, "ЗаписатьВДанныеПотокаИЗабрать", Параметры)); + КонецЦикла; + + ФоновыеЗадания.ОжидатьВсе(МассивЗаданий); + + Для Сч = 1 По МассивЗаданий.Количество() Цикл + Результат = МассивЗаданий[Сч - 1].Результат; + юТест.ПроверитьРавенство(Сч, Результат.Метка, + "Данные потока исполнения не должны затираться соседними фоновыми заданиями"); + юТест.ПроверитьРавенство(Ложь, Результат.ЕстьЧужое, + "Фоновое задание не должно видеть данные основного потока исполнения"); + КонецЦикла; + + ТекущийПоток().Данные.Удалить("изОсновногоПотока"); + +КонецПроцедуры + +Функция ПоложитьВДанныеПотокаОсвобождаемыйОбъект() Экспорт + + ИмяФайла = ПолучитьИмяВременногоФайла("tmp"); + ФайловыйПоток = Новый ФайловыйПоток(ИмяФайла, РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + + ТекущийПоток().Данные.Вставить("файл", ФайловыйПоток); + + // Возвращаем и сам поток исполнения, и объект - чтобы проверить их состояние после завершения задания + Возврат Новый Структура("ПотокИсполнения, ФайловыйПоток, ИмяФайла", + ТекущийПоток(), ФайловыйПоток, ИмяФайла); + +КонецФункции + +Процедура ТестДолжен_ПроверитьОсвобождениеДанныхПотокаИсполнения() Экспорт + + Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПоложитьВДанныеПотокаОсвобождаемыйОбъект"); + Задание.ОжидатьЗавершения(); + + Результат = Задание.Результат; + юТест.ПроверитьНеРавенство(Неопределено, Результат, "Задание должно вернуть результат"); + + юТест.ПроверитьРавенство(0, Результат.ПотокИсполнения.Данные.Количество(), + "Данные потока исполнения должны очищаться по завершении фонового задания"); + + ИсключениеБыло = Ложь; + Попытка + Результат.ФайловыйПоток.ТекущаяПозиция(); + Исключение + ИсключениеБыло = Истина; + КонецПопытки; + + юТест.ПроверитьРавенство(Истина, ИсключениеБыло, + "Значения данных потока исполнения должны принудительно освобождаться"); + + УдалитьФайлы(Результат.ИмяФайла); КонецПроцедуры From 1e5c1d0c54153f2f09fd146bb693f2be3742c8cd Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Tue, 25 Aug 2026 11:19:36 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=D0=A1=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Освобождение данных потока добирается только до значений, реализующих IDisposable среды CLR. Библиотекам этого мало: соединение с БД или занятая в пуле запись - это BSL-объект, и узнать о конце единицы исполнения им было неоткуда, кроме опроса списка фоновых заданий, который не видит ни запросов веб-сервера, ни последствий ФоновыеЗадания.Очистить(). Теперь по завершении потока исполнения поднимается событие ПриЗавершении (оно же OnTermination), на которое подписываются штатным ДобавитьОбработчик: ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ВернутьСоединение; Событие поднимается до очистки данных, поэтому обработчик ещё видит всё, что поток в них положил. Ошибка обработчика наружу не выпускается: у фонового задания завершение идёт в блоке finally и затёрло бы исходную ошибку, у веб-сервера выполняется после отправки ответа. Реестр подписок DefaultEventProcessor удерживает источник до конца работы движка, а поток исполнения живёт лишь до конца своей единицы исполнения. Поэтому в IEventProcessor добавлен RemoveAllHandlers, снимающий подписки источника, и завершение потока его вызывает. Метод объявлен с пустой реализацией по умолчанию, чтобы не ломать сторонние процессоры событий. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014ZfXJeyPJxZxtooYQzTt7s --- .../Threads/ExecutionThreadContext.cs | 60 +++++++++++++ .../DefaultEventProcessor.cs | 8 ++ src/ScriptEngine/Machine/IEventProcessor.cs | 12 +++ .../EventProcessorTests.cs | 85 +++++++++++++++++++ .../OneScript.Core.Tests.csproj | 1 + tests/tasks.os | 62 ++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 src/Tests/OneScript.Core.Tests/EventProcessorTests.cs diff --git a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs index 311047ecf..ad27a8e65 100644 --- a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs +++ b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs @@ -10,6 +10,8 @@ This Source Code Form is subject to the terms of the using OneScript.Contexts; using OneScript.Execution; using OneScript.StandardLibrary.Collections; +using ScriptEngine; +using ScriptEngine.Machine; using ScriptEngine.Machine.Contexts; namespace OneScript.StandardLibrary.Threads @@ -24,10 +26,25 @@ namespace OneScript.StandardLibrary.Threads /// Свойство Данные представляет собой хранилище, живущее ровно столько же, сколько живёт /// сам поток исполнения. Оно предназначено для библиотек, которым нужно хранить состояние /// в разрезе единицы исполнения (аналог thread-local хранилища). + /// + /// По завершении потока исполнения поднимается событие ПриЗавершении (оно же OnTermination), + /// на которое можно подписаться штатным способом: + /// + /// ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ВернутьСоединениеВПул; + /// + /// Обработчик вызывается до очистки данных потока, поэтому ещё видит их содержимое. + /// Это позволяет владельцам ресурсов узнавать о конце единицы исполнения вместо того, + /// чтобы опрашивать список фоновых заданий. /// [ContextClass("ПотокИсполнения", "ExecutionThread")] public sealed class ExecutionThreadContext : AutoContext, IDisposable { + /// + /// Имена события завершения потока исполнения. Событие поднимается под обоими именами, + /// поэтому подписаться можно как на русское, так и на английское. + /// + private static readonly string[] TerminationEventNames = { "ПриЗавершении", "OnTermination" }; + private static readonly ConditionalWeakTable Threads = new(); private ExecutionThreadContext(IBslProcess process) @@ -81,10 +98,53 @@ public static void Release(IBslProcess process) if (!Threads.TryGetValue(process, out var thread)) return; + thread.RaiseTerminationEvent(process); + Threads.Remove(process); thread.Dispose(); } + /// + /// Поднимает событие завершения потока исполнения. + /// + /// Событие поднимается до очистки данных, поэтому обработчик ещё видит всё, что поток + /// в них положил, и может, например, вернуть занятые ресурсы владельцу. + /// + /// Ошибка обработчика не выпускается наружу: поток завершается уже после того, как + /// код единицы исполнения отработал, и ронять на этом её результат нельзя. У фонового + /// задания завершение идёт в блоке finally и затёрло бы исходную ошибку, у веб-сервера + /// оно выполняется после отправки ответа. + /// + private void RaiseTerminationEvent(IBslProcess process) + { + var eventProcessor = process.Services.TryResolve(); + if (eventProcessor == null) + return; + + try + { + foreach (var eventName in TerminationEventNames) + { + try + { + eventProcessor.HandleEvent(this, eventName, Array.Empty(), process); + } + catch (Exception exception) + { + SystemLogger.Write( + $"WARNING! Error in execution thread termination handler '{eventName}': {exception.Message}"); + } + } + } + finally + { + // Реестр подписок держит источник до конца работы движка, а поток исполнения + // живёт лишь до конца своей единицы исполнения. Без снятия подписок каждый + // завершившийся поток оставался бы в реестре навсегда. + eventProcessor.RemoveAllHandlers(this); + } + } + public void Dispose() { foreach (var item in Data) diff --git a/src/ScriptEngine.HostedScript/DefaultEventProcessor.cs b/src/ScriptEngine.HostedScript/DefaultEventProcessor.cs index 24549e84b..6c283db1e 100644 --- a/src/ScriptEngine.HostedScript/DefaultEventProcessor.cs +++ b/src/ScriptEngine.HostedScript/DefaultEventProcessor.cs @@ -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) { diff --git a/src/ScriptEngine/Machine/IEventProcessor.cs b/src/ScriptEngine/Machine/IEventProcessor.cs index 9c667fd79..ab936356f 100644 --- a/src/ScriptEngine/Machine/IEventProcessor.cs +++ b/src/ScriptEngine/Machine/IEventProcessor.cs @@ -25,5 +25,17 @@ void RemoveHandler( string handlerMethod); void HandleEvent(IRuntimeContextInstance eventSource, string eventName, IValue[] eventArgs, IBslProcess process); + + /// + /// Снимает все подписки на события указанного источника. + /// + /// Нужен источникам, которые живут меньше самого процессора событий: без этого реестр + /// подписок удерживает такой источник до конца работы движка. Реализация по умолчанию + /// ничего не делает, чтобы не ломать сторонние процессоры событий. + /// + /// Источник, подписки на который нужно снять. + void RemoveAllHandlers(IRuntimeContextInstance eventSource) + { + } } } \ No newline at end of file diff --git a/src/Tests/OneScript.Core.Tests/EventProcessorTests.cs b/src/Tests/OneScript.Core.Tests/EventProcessorTests.cs new file mode 100644 index 000000000..37f78d5d8 --- /dev/null +++ b/src/Tests/OneScript.Core.Tests/EventProcessorTests.cs @@ -0,0 +1,85 @@ +/*---------------------------------------------------------- +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 OneScript.Execution; +using OneScript.StandardLibrary.Collections; +using OneScript.Values; +using ScriptEngine.HostedScript; +using ScriptEngine.Hosting; +using ScriptEngine.Machine; +using ScriptEngine.Machine.Contexts; +using Xunit; + +namespace OneScript.Core.Tests +{ + public class EventProcessorTests + { + private const string HandlerScript = @" + Перем Вызовов Экспорт; + + Процедура Обработчик() Экспорт + Вызовов = Вызовов + 1; + КонецПроцедуры + + Вызовов = 0;"; + + private static (UserScriptContextInstance Handler, IBslProcess Process) CreateHandler() + { + var engine = DefaultEngineBuilder.Create().SetDefaultOptions().Build(); + engine.Initialize(); + + var process = engine.NewProcess(); + var handler = engine.AttachedScriptsFactory.LoadFromString( + engine.GetCompilerService(), HandlerScript, process); + + return (handler, process); + } + + private static decimal CallCount(UserScriptContextInstance handler) + { + var propertyIndex = handler.GetPropertyNumber("Вызовов"); + return (decimal)(BslNumericValue)handler.GetPropValue(propertyIndex); + } + + [Fact] + public void RemoveAllHandlers_UnsubscribesEverythingOfTheSource() + { + var (handler, process) = CreateHandler(); + var eventSource = new ArrayImpl(); + IEventProcessor processor = new DefaultEventProcessor(); + + processor.AddHandler(eventSource, "ПриЗавершении", handler, "Обработчик"); + processor.HandleEvent(eventSource, "ПриЗавершении", Array.Empty(), process); + + CallCount(handler).Should().Be(1, "подписка должна работать до её снятия"); + + processor.RemoveAllHandlers(eventSource); + processor.HandleEvent(eventSource, "ПриЗавершении", Array.Empty(), process); + + CallCount(handler).Should().Be(1, "после снятия подписок обработчик вызываться не должен"); + } + + [Fact] + public void RemoveAllHandlers_KeepsSubscriptionsOfOtherSources() + { + var (handler, process) = CreateHandler(); + var releasedSource = new ArrayImpl(); + var aliveSource = new ArrayImpl(); + IEventProcessor processor = new DefaultEventProcessor(); + + processor.AddHandler(releasedSource, "ПриЗавершении", handler, "Обработчик"); + processor.AddHandler(aliveSource, "ПриЗавершении", handler, "Обработчик"); + + processor.RemoveAllHandlers(releasedSource); + processor.HandleEvent(aliveSource, "ПриЗавершении", Array.Empty(), process); + + CallCount(handler).Should().Be(1, "снятие подписок одного источника не трогает другие"); + } + } +} diff --git a/src/Tests/OneScript.Core.Tests/OneScript.Core.Tests.csproj b/src/Tests/OneScript.Core.Tests/OneScript.Core.Tests.csproj index f1708dd92..cba21a483 100644 --- a/src/Tests/OneScript.Core.Tests/OneScript.Core.Tests.csproj +++ b/src/Tests/OneScript.Core.Tests/OneScript.Core.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/tasks.os b/tests/tasks.os index a32acb377..1e64e368b 100644 --- a/tests/tasks.os +++ b/tests/tasks.os @@ -5,6 +5,9 @@ Перем СобытиеВызвано; +Перем ЗавершениеПотокаЗафиксировано; +Перем ЭлементовВДанныхПриЗавершении; + //////////////////////////////////////////////////////////////////// // Программный интерфейс @@ -27,6 +30,8 @@ ВсеТесты.Добавить("ТестДолжен_ПроверитьУникальностьПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_ПроверитьИзоляциюДанныхПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_ПроверитьОсвобождениеДанныхПотокаИсполнения"); + ВсеТесты.Добавить("ТестДолжен_ВызватьОбработчикЗавершенияПотокаИсполнения"); + ВсеТесты.Добавить("ТестДолжен_НеРонятьЗаданиеИзЗаОшибкиВОбработчикеЗавершения"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьТекущее"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьФоновыеЗадания"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПоискТекущегоСредиМножестваЗавершенных"); @@ -391,6 +396,63 @@ КонецПроцедуры +Процедура ПриЗавершенииПотокаЗадания() Экспорт + + ЗавершениеПотокаЗафиксировано = Истина; + // Обработчик должен успевать увидеть данные потока до их очистки + ЭлементовВДанныхПриЗавершении = ТекущийПоток().Данные.Количество(); + +КонецПроцедуры + +Функция ПодписатьсяНаЗавершениеПотока() Экспорт + + ТекущийПоток().Данные.Вставить("ресурс", "значение"); + ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ПриЗавершенииПотокаЗадания; + Возврат ТекущийПоток(); + +КонецФункции + +Процедура ТестДолжен_ВызватьОбработчикЗавершенияПотокаИсполнения() Экспорт + + ЗавершениеПотокаЗафиксировано = Ложь; + ЭлементовВДанныхПриЗавершении = -1; + + Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПодписатьсяНаЗавершениеПотока"); + Задание.ОжидатьЗавершения(); + + юТест.ПроверитьРавенство(Истина, ЗавершениеПотокаЗафиксировано, + "Обработчик ПриЗавершении должен вызываться по завершении потока исполнения"); + + юТест.ПроверитьРавенство(1, ЭлементовВДанныхПриЗавершении, + "Обработчик должен видеть данные потока до их очистки"); + + юТест.ПроверитьРавенство(0, Задание.Результат.Данные.Количество(), + "После обработчика данные потока должны быть очищены"); + +КонецПроцедуры + +Процедура ПриЗавершенииПотокаСИсключением() Экспорт + ВызватьИсключение "Ошибка внутри обработчика завершения"; +КонецПроцедуры + +Функция ПодписатьсяНаПадающийОбработчик() Экспорт + ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ПриЗавершенииПотокаСИсключением; + Возврат "задание отработало"; +КонецФункции + +Процедура ТестДолжен_НеРонятьЗаданиеИзЗаОшибкиВОбработчикеЗавершения() Экспорт + + Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПодписатьсяНаПадающийОбработчик"); + Задание.ОжидатьЗавершения(); + + юТест.ПроверитьРавенство("задание отработало", Задание.Результат, + "Ошибка в обработчике завершения не должна ломать результат задания"); + + юТест.ПроверитьРавенство(СостояниеФоновогоЗадания.Завершено, Задание.Состояние, + "Ошибка в обработчике завершения не должна менять состояние задания"); + +КонецПроцедуры + Процедура ТестДолжен_ПроверитьЧтоВИнформацииОбОшибкеЕстьСтекВызовов() Экспорт Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПроцедураСИсключением"); From e6df8647851a959396719d57b4d6fd501d3294aa Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Tue, 25 Aug 2026 14:56:25 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три пробела, найденные Copilot в PR #1725. Поток исполнения основного скрипта не завершался: Release вызывался только для фоновых заданий и запросов веб-сервера, а Process.Start лишь останавливал движок. Из-за этого у основного скрипта не наступало ПриЗавершении, данные не очищались, а значения с IDisposable не освобождались детерминированно. Теперь поток завершается в блоке finally основного процесса, до остановки движка - обработчику ещё нужно исполнять bsl-код. Освобождение данных не было изолировано по значениям: ошибка на первом же прерывала цикл, остальные значения оставались неосвобождёнными, карта не очищалась, а исключение выходило из блока finally фонового задания и превращало ОжидатьЗавершения в AggregateException. Теперь каждое значение освобождается независимо, ошибка уходит предупреждением в лог, а очистка карты идёт через finally. Гарантии веб-пути не были покрыты автотестом - проверялись только вручную. Добавлен tests/webserver-thread.os: запрос кладёт значение в данные своего потока, падает, обработчик исключений читает то же значение и возвращает его в ответе, после чего проверяется срабатывание ПриЗавершении. Готовность сервера ждётся опросом, а не фиксированной паузой. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014ZfXJeyPJxZxtooYQzTt7s --- .../Threads/ExecutionThreadContext.cs | 31 +++- src/ScriptEngine.HostedScript/Process.cs | 5 + tests/tasks.os | 113 ++++++++++++- tests/webserver-thread.os | 151 ++++++++++++++++++ 4 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 tests/webserver-thread.os diff --git a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs index ad27a8e65..32f1950c3 100644 --- a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs +++ b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs @@ -145,14 +145,37 @@ private void RaiseTerminationEvent(IBslProcess process) } } + /// + /// Освобождает данные потока исполнения. + /// + /// Каждое значение освобождается независимо: ошибка на одном не мешает освободить + /// остальные и не выпускается наружу. Поток завершается уже после того, как код + /// единицы исполнения отработал, и ронять на этом её результат нельзя. + /// public void Dispose() { - foreach (var item in Data) + try { - (item.Value as IDisposable)?.Dispose(); - } + foreach (var item in Data) + { + if (item.Value is not IDisposable disposable) + continue; - Data.Clear(); + try + { + disposable.Dispose(); + } + catch (Exception exception) + { + SystemLogger.Write( + $"WARNING! Error releasing execution thread data '{item.Key}': {exception.Message}"); + } + } + } + finally + { + Data.Clear(); + } } } } diff --git a/src/ScriptEngine.HostedScript/Process.cs b/src/ScriptEngine.HostedScript/Process.cs index 39a36d266..fa850f7cb 100644 --- a/src/ScriptEngine.HostedScript/Process.cs +++ b/src/ScriptEngine.HostedScript/Process.cs @@ -6,6 +6,7 @@ This Source Code Form is subject to the terms of the ----------------------------------------------------------*/ using System; using OneScript.Execution; +using OneScript.StandardLibrary.Threads; using ScriptEngine.Machine; namespace ScriptEngine.HostedScript @@ -50,6 +51,10 @@ public int Start() } finally { + // Поток исполнения основного скрипта закончился здесь. Освобождается до + // остановки движка: обработчику ПриЗавершении ещё нужно исполнять bsl-код. + ExecutionThreadContext.Release(_bslProcess); + _engine.Debugger.NotifyProcessExit(exitCode); _engine.Dispose(); _engine = null; diff --git a/tests/tasks.os b/tests/tasks.os index 1e64e368b..ca3ee3634 100644 --- a/tests/tasks.os +++ b/tests/tasks.os @@ -32,6 +32,8 @@ ВсеТесты.Добавить("ТестДолжен_ПроверитьОсвобождениеДанныхПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_ВызватьОбработчикЗавершенияПотокаИсполнения"); ВсеТесты.Добавить("ТестДолжен_НеРонятьЗаданиеИзЗаОшибкиВОбработчикеЗавершения"); + ВсеТесты.Добавить("ТестДолжен_ОсвободитьОстальныеДанныеПриОшибкеОсвобождения"); + ВсеТесты.Добавить("ТестДолжен_ОсвободитьДанныеПотокаОсновногоСкрипта"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьТекущее"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПотокобезопасностьПолучитьФоновыеЗадания"); ВсеТесты.Добавить("ТестДолжен_ПроверитьПоискТекущегоСредиМножестваЗавершенных"); @@ -450,7 +452,116 @@ юТест.ПроверитьРавенство(СостояниеФоновогоЗадания.Завершено, Задание.Состояние, "Ошибка в обработчике завершения не должна менять состояние задания"); - + +КонецПроцедуры + +// Создаёт значение, освобождение которого завершается ошибкой. +// +// ЗаписьДанных при освобождении сбрасывает буфер в нижележащий поток, а он уже закрыт. +// +Функция ПадающийПриОсвобожденииРесурс() + + ФайловыйПоток = Новый ФайловыйПоток(ПолучитьИмяВременногоФайла("tmp"), + РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + Писатель = Новый ЗаписьДанных(ФайловыйПоток); + Писатель.ЗаписатьСтроку("данные, которые придётся сбрасывать при закрытии"); + ФайловыйПоток.Закрыть(); + + Возврат Писатель; + +КонецФункции + +Функция ПоложитьВДанныеПадающийИИсправныйРесурсы() Экспорт + + Исправный = Новый ФайловыйПоток(ПолучитьИмяВременногоФайла("tmp"), + РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + + ТекущийПоток().Данные.Вставить("падающий", ПадающийПриОсвобожденииРесурс()); + ТекущийПоток().Данные.Вставить("исправный", Исправный); + + Возврат Новый Структура("Поток, Исправный", ТекущийПоток(), Исправный); + +КонецФункции + +// Ошибка освобождения одного значения не должна ни ронять задание, ни оставлять +// неосвобождёнными остальные значения. +// +Процедура ТестДолжен_ОсвободитьОстальныеДанныеПриОшибкеОсвобождения() Экспорт + + Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПоложитьВДанныеПадающийИИсправныйРесурсы"); + Задание.ОжидатьЗавершения(); + + юТест.ПроверитьРавенство(СостояниеФоновогоЗадания.Завершено, Задание.Состояние, + "Ошибка освобождения значения не должна менять состояние задания"); + + Результат = Задание.Результат; + юТест.ПроверитьНеРавенство(Неопределено, Результат, "Задание должно вернуть результат"); + + юТест.ПроверитьРавенство(0, Результат.Поток.Данные.Количество(), + "Данные потока должны очищаться даже при ошибке освобождения одного из значений"); + + ИсправныйОсвобожден = Ложь; + Попытка + Результат.Исправный.ТекущаяПозиция(); + Исключение + ИсправныйОсвобожден = Истина; + КонецПопытки; + + юТест.ПроверитьРавенство(Истина, ИсправныйОсвобожден, + "Исправное значение должно освобождаться независимо от ошибки на соседнем"); + +КонецПроцедуры + +// Поток исполнения основного скрипта тоже завершается: обработчик ПриЗавершении должен +// сработать по окончании скрипта, а не остаться невызванным до конца процесса. +// +// Проверяется дочерним oscript: изнутри самого скрипта момент его завершения не наблюдаем. +// Дочерний процесс подписывается на завершение своего потока и пишет из обработчика файл-метку, +// родитель дожидается завершения и смотрит на метку. +// +Процедура ТестДолжен_ОсвободитьДанныеПотокаОсновногоСкрипта() Экспорт + + ФайлМетки = ПолучитьИмяВременногоФайла("txt"); + ФайлСкрипта = ПолучитьИмяВременногоФайла("os"); + + ТекстСкрипта = Новый ЗаписьТекста(ФайлСкрипта, КодировкаТекста.UTF8); + ТекстСкрипта.ЗаписатьСтроку("Процедура ПриЗавершении() Экспорт"); + ТекстСкрипта.ЗаписатьСтроку(" Запись = Новый ЗаписьТекста(""" + СтрЗаменить(ФайлМетки, "\", "\\") + """);"); + ТекстСкрипта.ЗаписатьСтроку(" Запись.ЗаписатьСтроку(""элементов="" + ТекущийПоток().Данные.Количество());"); + ТекстСкрипта.ЗаписатьСтроку(" Запись.Закрыть();"); + ТекстСкрипта.ЗаписатьСтроку("КонецПроцедуры"); + ТекстСкрипта.ЗаписатьСтроку("ТекущийПоток().Данные.Вставить(""ключ"", ""значение"");"); + ТекстСкрипта.ЗаписатьСтроку("ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ПриЗавершении;"); + ТекстСкрипта.Закрыть(); + + Попытка + + Процесс = СоздатьПроцесс("dotnet """ + КаталогПрограммы() + "/oscript.dll"" """ + ФайлСкрипта + """"); + Процесс.Запустить(); + Процесс.ОжидатьЗавершения(); + + юТест.ПроверитьРавенство(0, Процесс.КодВозврата, "Дочерний скрипт должен отработать без ошибки"); + + юТест.ПроверитьРавенство(Истина, Новый Файл(ФайлМетки).Существует(), + "Обработчик ПриЗавершении должен вызываться и для потока основного скрипта"); + + Чтение = Новый ЧтениеТекста(ФайлМетки); + Метка = СокрЛП(Чтение.ПрочитатьСтроку()); + Чтение.Закрыть(); + + юТест.ПроверитьРавенство("элементов=1", Метка, + "Обработчик должен видеть данные потока основного скрипта до их очистки"); + + Исключение + УдалитьФайлы(ФайлСкрипта); + ВызватьИсключение; + КонецПопытки; + + УдалитьФайлы(ФайлСкрипта); + Если Новый Файл(ФайлМетки).Существует() Тогда + УдалитьФайлы(ФайлМетки); + КонецЕсли; + КонецПроцедуры Процедура ТестДолжен_ПроверитьЧтоВИнформацииОбОшибкеЕстьСтекВызовов() Экспорт diff --git a/tests/webserver-thread.os b/tests/webserver-thread.os new file mode 100644 index 000000000..c39f09675 --- /dev/null +++ b/tests/webserver-thread.os @@ -0,0 +1,151 @@ +/////////////////////////////////////////////////////////////////////// +// +// Поток исполнения при обработке запросов веб-сервером. +// +// Каждый запрос обслуживается собственным потоком исполнения. Проверяется, что обработчик +// исключений работает в потоке упавшего обработчика запроса и видит его данные, а по окончании +// обработки запроса поток завершается с вызовом ПриЗавершении. +// +/////////////////////////////////////////////////////////////////////// + +Перем юТест; + +// Число - Порт тестового веб-сервера. +Перем ПортТестовогоСервера; +// Число - Сколько раз сработал обработчик завершения потока запроса. +Перем ЗавершенийЗафиксировано; +// ВебСервер - Тестовый сервер. Виден фоновому заданию, которое его поднимает. +Перем Сервер; + +Функция ПолучитьСписокТестов(ЮнитТестирование) Экспорт + + юТест = ЮнитТестирование; + + ВсеТесты = Новый Массив; + ВсеТесты.Добавить("ТестДолжен_ОбработатьИсключениеВПотокеЗапросаИЗавершитьЕго"); + + Возврат ВсеТесты; + +КонецФункции + +// Обработчик завершения потока запроса. +// +Процедура ПриЗавершенииПотокаЗапроса() Экспорт + ЗавершенийЗафиксировано = ЗавершенийЗафиксировано + 1; +КонецПроцедуры + +Процедура ОбработчикЗапроса(Контекст, СледующийОбработчик) Экспорт + + Если Контекст.Запрос.Путь = "/ping" Тогда + Контекст.Ответ.Записать("pong"); + Возврат; + КонецЕсли; + + ДобавитьОбработчик ТекущийПоток().ПриЗавершении, ЭтотОбъект.ПриЗавершенииПотокаЗапроса; + + ТекущийПоток().Данные.Вставить("метка", "метка-" + Контекст.ИдентификаторТрассировки); + + ВызватьИсключение "Падаем намеренно"; + +КонецПроцедуры + +Процедура ОбработчикИсключений(Контекст) Экспорт + + Контекст.Ответ.КодСостояния = 500; + // Значение положил обработчик запроса. Виден оно здесь только если обработчик исключений + // работает в том же потоке исполнения. + Контекст.Ответ.Записать("" + ТекущийПоток().Данные.Получить("метка")); + +КонецПроцедуры + +Процедура Вебсервер() Экспорт + + Сервер = Новый ВебСервер(ПортТестовогоСервера); + Сервер.ДобавитьОбработчикЗапросов(ЭтотОбъект, "ОбработчикЗапроса"); + Сервер.ДобавитьОбработчикИсключений(ЭтотОбъект, "ОбработчикИсключений"); + Сервер.Запустить(); + +КонецПроцедуры + +// Дожидается готовности сервера опросом, а не фиксированной паузой: на нагруженной машине +// подъём занимает непредсказуемое время, и пауза наугад делает тест нестабильным. +// +Функция ДождатьсяГотовностиСервера(Знач Соединение, Знач ПределОжидания = 30000) + + Начало = ТекущаяУниверсальнаяДатаВМиллисекундах(); + + Пока ТекущаяУниверсальнаяДатаВМиллисекундах() - Начало < ПределОжидания Цикл + Попытка + Ответ = Соединение.Получить(Новый HTTPЗапрос("/ping")); + Если Ответ.КодСостояния = 200 Тогда + Возврат Истина; + КонецЕсли; + Исключение + // Сервер ещё не поднялся - пробуем снова + КонецПопытки; + Приостановить(50); + КонецЦикла; + + Возврат Ложь; + +КонецФункции + +// Дожидается, пока обработчик завершения потока отработает: область сервисов запроса +// освобождается уже после отправки ответа, поэтому счётчик растёт чуть позже ответа. +// +Функция ДождатьсяЗавершенийПотоков(Знач Ожидается, Знач ПределОжидания = 30000) + + Начало = ТекущаяУниверсальнаяДатаВМиллисекундах(); + + Пока ТекущаяУниверсальнаяДатаВМиллисекундах() - Начало < ПределОжидания Цикл + Если ЗавершенийЗафиксировано >= Ожидается Тогда + Возврат Истина; + КонецЕсли; + Приостановить(50); + КонецЦикла; + + Возврат Ложь; + +КонецФункции + +Процедура ТестДолжен_ОбработатьИсключениеВПотокеЗапросаИЗавершитьЕго() Экспорт + + ПортТестовогоСервера = 8182; + ЗавершенийЗафиксировано = 0; + + МенеджерФоновыхЗаданий = Новый МенеджерФоновыхЗаданий; + МенеджерФоновыхЗаданий.Выполнить(ЭтотОбъект, "Вебсервер"); + + Попытка + + Соединение = Новый HTTPСоединение("http://127.0.0.1:" + ПортТестовогоСервера); + + юТест.ПроверитьРавенство(Истина, ДождатьсяГотовностиСервера(Соединение), + "Тестовый веб-сервер должен подняться"); + + // Проверки готовности тоже создают потоки исполнения - считаем только целевой запрос + ЗавершенийЗафиксировано = 0; + + Ответ = Соединение.Получить(Новый HTTPЗапрос("/")); + + юТест.ПроверитьРавенство(500, Ответ.КодСостояния, + "Обработчик исключений должен отработать"); + + ТелоОтвета = Ответ.ПолучитьТелоКакСтроку(); + юТест.ПроверитьРавенство(Истина, СтрНачинаетсяС(ТелоОтвета, "метка-"), + "Обработчик исключений должен видеть данные потока упавшего обработчика запроса," + + " получено: " + ТелоОтвета); + + юТест.ПроверитьРавенство(Истина, ДождатьсяЗавершенийПотоков(1), + "По окончании обработки запроса должен сработать обработчик ПриЗавершении"); + + Исключение + Сервер.Остановить(); + МенеджерФоновыхЗаданий.Очистить(); + ВызватьИсключение; + КонецПопытки; + + Сервер.Остановить(); + МенеджерФоновыхЗаданий.Очистить(); + +КонецПроцедуры From eb5a99684ff96827d1e8a343b0e79bba475ab7b5 Mon Sep 17 00:00:00 2001 From: Nikita Fedkin Date: Tue, 25 Aug 2026 15:09:19 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=D0=98=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=81=D0=B2=D0=BE=D0=B1=D0=BE=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D1=83=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B0=20=D0=B7=D0=B0=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По второй порции замечаний ревью PR #1725. Освобождение перебирало живую карту данных: освобождаемое значение может изменить эти же данные, и следующий шаг перебора сорвался бы уже вне защиты, окружающей само освобождение, - ошибка вышла бы из Release. Значения снимаются в отдельный список до начала освобождения. Обработчик Ctrl+C в ConsoleContext создавал процесс и исполнял в нем bsl-код, не завершая поток исполнения. Завершение добавлено в finally. Тест на изоляцию ошибок освобождения оставлял после себя два временных файла: закрытие потоков файлы не удаляет. Пути возвращаются заданием и удаляются после проверок. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014ZfXJeyPJxZxtooYQzTt7s --- .../Text/ConsoleContext.cs | 14 +++- .../Threads/ExecutionThreadContext.cs | 7 +- tests/tasks.os | 82 ++++++++++++++----- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src/OneScript.StandardLibrary/Text/ConsoleContext.cs b/src/OneScript.StandardLibrary/Text/ConsoleContext.cs index 2ebe5f7c3..8a1435638 100644 --- a/src/OneScript.StandardLibrary/Text/ConsoleContext.cs +++ b/src/OneScript.StandardLibrary/Text/ConsoleContext.cs @@ -11,6 +11,7 @@ This Source Code Form is subject to the terms of the using OneScript.Exceptions; using OneScript.Execution; using OneScript.StandardLibrary.Binary; +using OneScript.StandardLibrary.Threads; using OneScript.Values; using ScriptEngine.Machine; using ScriptEngine.Machine.Contexts; @@ -272,12 +273,21 @@ private void ConsoleOnCancelKeyPress(object sender, ConsoleCancelEventArgs e) return; var process = _executionContext.Services.Resolve().NewProcess(); - + var cancelVar = Variable.Create(BslBooleanValue.False, "Cancel"); var reference = Variable.CreateReference(cancelVar, "Cancel"); var args = new IValue[] { reference }; - eventProcessor.HandleEvent(this, ConsoleCancelKeyEvent, args, process); + try + { + eventProcessor.HandleEvent(this, ConsoleCancelKeyEvent, args, process); + } + finally + { + // Обработчик отработал в собственном потоке исполнения - завершаем его + ExecutionThreadContext.Release(process); + } + e.Cancel = reference.Value.AsBoolean(); } } diff --git a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs index 32f1950c3..5fb67a4b9 100644 --- a/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs +++ b/src/OneScript.StandardLibrary/Threads/ExecutionThreadContext.cs @@ -6,6 +6,7 @@ This Source Code Form is subject to the terms of the ----------------------------------------------------------*/ using System; +using System.Linq; using System.Runtime.CompilerServices; using OneScript.Contexts; using OneScript.Execution; @@ -151,12 +152,16 @@ private void RaiseTerminationEvent(IBslProcess process) /// Каждое значение освобождается независимо: ошибка на одном не мешает освободить /// остальные и не выпускается наружу. Поток завершается уже после того, как код /// единицы исполнения отработал, и ронять на этом её результат нельзя. + /// + /// Значения снимаются в отдельный список до начала освобождения: освобождаемое значение + /// может изменить эти же данные, и перебор живой карты сорвался бы на следующем шаге - + /// уже вне защиты, окружающей само освобождение. /// public void Dispose() { try { - foreach (var item in Data) + foreach (var item in Data.ToArray()) { if (item.Value is not IDisposable disposable) continue; diff --git a/tests/tasks.os b/tests/tasks.os index ca3ee3634..d39f48ed4 100644 --- a/tests/tasks.os +++ b/tests/tasks.os @@ -459,10 +459,12 @@ // // ЗаписьДанных при освобождении сбрасывает буфер в нижележащий поток, а он уже закрыт. // -Функция ПадающийПриОсвобожденииРесурс() +// Параметры: +// ИмяФайла - Строка - Файл, поверх которого строится значение. +// +Функция ПадающийПриОсвобожденииРесурс(Знач ИмяФайла) - ФайловыйПоток = Новый ФайловыйПоток(ПолучитьИмяВременногоФайла("tmp"), - РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + ФайловыйПоток = Новый ФайловыйПоток(ИмяФайла, РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); Писатель = Новый ЗаписьДанных(ФайловыйПоток); Писатель.ЗаписатьСтроку("данные, которые придётся сбрасывать при закрытии"); ФайловыйПоток.Закрыть(); @@ -473,13 +475,29 @@ Функция ПоложитьВДанныеПадающийИИсправныйРесурсы() Экспорт - Исправный = Новый ФайловыйПоток(ПолучитьИмяВременногоФайла("tmp"), - РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + ФайлПадающего = ПолучитьИмяВременногоФайла("tmp"); + ФайлИсправного = ПолучитьИмяВременногоФайла("tmp"); - ТекущийПоток().Данные.Вставить("падающий", ПадающийПриОсвобожденииРесурс()); + Исправный = Новый ФайловыйПоток(ФайлИсправного, РежимОткрытияФайла.Создать, ДоступКФайлу.Запись); + + ТекущийПоток().Данные.Вставить("падающий", ПадающийПриОсвобожденииРесурс(ФайлПадающего)); ТекущийПоток().Данные.Вставить("исправный", Исправный); - Возврат Новый Структура("Поток, Исправный", ТекущийПоток(), Исправный); + // Пути возвращаются вызывающему: закрытие потоков файлы не удаляет + Возврат Новый Структура("Поток, Исправный, Файлы", ТекущийПоток(), Исправный, + ОбщегоНазначенияМассивИз(ФайлПадающего, ФайлИсправного)); + +КонецФункции + +// Собирает массив из переданных значений. +// +Функция ОбщегоНазначенияМассивИз(Знач Первое, Знач Второе) + + Результат = Новый Массив; + Результат.Добавить(Первое); + Результат.Добавить(Второе); + + Возврат Результат; КонецФункции @@ -491,24 +509,50 @@ Задание = ФоновыеЗадания.Выполнить(ЭтотОбъект, "ПоложитьВДанныеПадающийИИсправныйРесурсы"); Задание.ОжидатьЗавершения(); - юТест.ПроверитьРавенство(СостояниеФоновогоЗадания.Завершено, Задание.Состояние, - "Ошибка освобождения значения не должна менять состояние задания"); - Результат = Задание.Результат; - юТест.ПроверитьНеРавенство(Неопределено, Результат, "Задание должно вернуть результат"); - - юТест.ПроверитьРавенство(0, Результат.Поток.Данные.Количество(), - "Данные потока должны очищаться даже при ошибке освобождения одного из значений"); - ИсправныйОсвобожден = Ложь; Попытка - Результат.Исправный.ТекущаяПозиция(); + + юТест.ПроверитьРавенство(СостояниеФоновогоЗадания.Завершено, Задание.Состояние, + "Ошибка освобождения значения не должна менять состояние задания"); + + юТест.ПроверитьНеРавенство(Неопределено, Результат, "Задание должно вернуть результат"); + + юТест.ПроверитьРавенство(0, Результат.Поток.Данные.Количество(), + "Данные потока должны очищаться даже при ошибке освобождения одного из значений"); + + ИсправныйОсвобожден = Ложь; + Попытка + Результат.Исправный.ТекущаяПозиция(); + Исключение + ИсправныйОсвобожден = Истина; + КонецПопытки; + + юТест.ПроверитьРавенство(Истина, ИсправныйОсвобожден, + "Исправное значение должно освобождаться независимо от ошибки на соседнем"); + Исключение - ИсправныйОсвобожден = Истина; + УдалитьВременныеФайлы(Результат); + ВызватьИсключение; КонецПопытки; - юТест.ПроверитьРавенство(Истина, ИсправныйОсвобожден, - "Исправное значение должно освобождаться независимо от ошибки на соседнем"); + УдалитьВременныеФайлы(Результат); + +КонецПроцедуры + +// Удаляет временные файлы, созданные заданием: закрытие потоков их не удаляет. +// +Процедура УдалитьВременныеФайлы(Знач Результат) + + Если Результат = Неопределено Тогда + Возврат; + КонецЕсли; + + Для Каждого ИмяФайла Из Результат.Файлы Цикл + Если Новый Файл(ИмяФайла).Существует() Тогда + УдалитьФайлы(ИмяФайла); + КонецЕсли; + КонецЦикла; КонецПроцедуры