From 4023b72b1eeebf5e1b41d7fc5a213dc4685776d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:46:26 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=87=D0=B8=D1=82=D0=B0=D1=82=D1=8C=D0=A1?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D1=83():=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=8B=D1=80=D0=BE=D0=B5=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Событийный API (BeginOutputReadLine) режет вывод на строки и выбрасывает терминаторы, из-за чего обертке приходилось синтезировать разделители заново. Отложенный синтез порождал гонку с конкурентным читателем: если читатель вычерпывал буфер точно по границе строки, разделитель, добавленный перед следующей порцией данных, читался как лишняя пустая строка. Кроме того, вывод, не завершенный переводом строки (например, приглашение ко вводу), застревал внутри .NET до завершения процесса. Теперь фоновая задача перекачивает сырые символы из StandardOutput/StandardError в буфер как есть: - Прочитать() возвращает точную копию вывода процесса, включая переводы строк - без синтеза, без подрезки и без нормализации разделителей; - ПрочитатьСтроку() отдает строку, только когда ее терминатор уже в буфере либо поток закончился: фантомные пустые и рваные строки невозможны по построению, пустая последняя строка и вывод без финального перевода строки не теряются; - ОжидатьЗавершения() без таймаута дожидается выкачивания потоков, сохраняя гарантию "после ожидания виден весь вывод". Тесты process.os проверяют контракт точно: эталоны включают завершающий перевод строки вместо срезания его СокрП. В showversion.os вывод сравнивается с версией через СокрЛП, эталон передается первым аргументом, чтобы метки Ожидали/Получено соответствовали смыслу. Fixes EvilBeaver#1726 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013YSVPuji9Ge244GRqfawP9 --- .../Processes/ProcessContext.cs | 21 +- .../Processes/ProcessOutputWrapper.cs | 196 ++++++++++----- .../ProcessOutputWrapperTest.cs | 233 ++++++++++++++++++ tests/process.os | 46 +++- tests/showversion.os | 5 +- 5 files changed, 416 insertions(+), 85 deletions(-) create mode 100644 src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs diff --git a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs index a1ee50e77..d3d43d6b3 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs @@ -29,6 +29,8 @@ public class ProcessContext : AutoContext, IDisposable private StdTextReadStream _stdOutContext; private StdTextReadStream _stdErrContext; private StdTextWriteStream _stdInContext; + private ProcessOutputWrapper _stdOutWrapper; + private ProcessOutputWrapper _stdErrWrapper; private readonly IValue _outputEncoding; @@ -107,15 +109,14 @@ public void Start() if (IsOutputRedirected) { - var stream = new ProcessOutputWrapper(_p, ProcessOutputWrapper.OutputVariant.Stdout); - stream.StartReading(); - _stdOutContext = new StdTextReadStream(stream); + _stdOutWrapper = new ProcessOutputWrapper(_p.StandardOutput); + _stdOutWrapper.StartReading(); + _stdOutContext = new StdTextReadStream(_stdOutWrapper); - stream = new ProcessOutputWrapper(_p, ProcessOutputWrapper.OutputVariant.Stderr); - stream.StartReading(); - - _stdErrContext = new StdTextReadStream(stream); + _stdErrWrapper = new ProcessOutputWrapper(_p.StandardError); + _stdErrWrapper.StartReading(); + _stdErrContext = new StdTextReadStream(_stdErrWrapper); } } @@ -176,6 +177,12 @@ public bool WaitForExit(IValue timeout = null) if (timeout == null) { _p.WaitForExit(); + + // Прочитать() после ожидания должен видеть весь вывод: + // дожидаемся, пока фоновое чтение доберет хвост потоков + _stdOutWrapper?.WaitSourceDrained(); + _stdErrWrapper?.WaitSourceDrained(); + return true; } diff --git a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs index afcd9800a..8a88fa542 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs @@ -1,39 +1,50 @@ -/*---------------------------------------------------------- -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 +/*---------------------------------------------------------- +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.IO; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Text; -using sys = System.Diagnostics; +using System.Threading.Tasks; + +[assembly: InternalsVisibleTo("OneScript.StandardLibrary.Tests")] namespace OneScript.StandardLibrary.Processes { + /// + /// Неблокирующий читатель поверх потока вывода процесса + /// (Process.StandardOutput/StandardError). Фоновая задача перекачивает + /// сырые символы источника в буфер, включая настоящие переводы строк, + /// записанные процессом. + /// Событийный API (BeginOutputReadLine) не используется: он вырезает + /// терминаторы строк, и их обратный синтез порождал гонку (issue #1726), + /// а вывод, не завершенный переводом строки, застревал в .NET до конца процесса. + /// class ProcessOutputWrapper : TextReader { - private readonly sys.Process _process; - private readonly OutputVariant _variant; + private readonly TextReader _source; private readonly StringBuilder _buffer = new StringBuilder(4096); private int _bufferIndex = 0; - private bool AlreadyReading { get; set; } + // Пишутся пампом и читаются читателями только под lock(_buffer) + private bool _streamEnded; + private Exception _pumpError; - private Encoding Encoding { get; set; } + private volatile bool _stopRequested; - public enum OutputVariant - { - Stdout, - Stderr - } + private Task _pumpTask; + + private bool AlreadyReading { get; set; } - public ProcessOutputWrapper(sys.Process process, OutputVariant variant) + public ProcessOutputWrapper(TextReader source) { - _process = process; - _variant = variant; + _source = source; } public void StartReading() @@ -41,56 +52,80 @@ public void StartReading() if (AlreadyReading) return; - if (_variant == OutputVariant.Stdout) - { - Encoding = _process.StartInfo.StandardOutputEncoding; - _process.OutputDataReceived += StreamDataReceived; - _process.BeginOutputReadLine(); - } - else - { - Encoding = _process.StartInfo.StandardErrorEncoding; - _process.ErrorDataReceived += StreamDataReceived; - _process.BeginErrorReadLine(); - } + _pumpTask = Task.Run(() => PumpAsync(_source)); AlreadyReading = true; } - private void StopReading() + private async Task PumpAsync(TextReader reader) { - if (_variant == OutputVariant.Stdout) + var chunk = new char[4096]; + try { - _process.OutputDataReceived -= StreamDataReceived; + while (!_stopRequested) + { + int read = await reader.ReadAsync(chunk, 0, chunk.Length).ConfigureAwait(false); + if (read == 0) + break; + + lock (_buffer) + { + _buffer.Append(chunk, 0, read); + } + } } - else + catch (Exception e) { - _process.ErrorDataReceived -= StreamDataReceived; + // Ошибка источника перебрасывается читателю, когда он вычитает + // накопленные данные (см. ThrowIfPumpFailed). После остановки + // чтения (Dispose) источник закрыт, его ошибки ожидаемы. + if (!_stopRequested) + { + lock (_buffer) + { + _pumpError = e; + } + } } - } - - private void StreamDataReceived(object sender, sys.DataReceivedEventArgs e) - { - if (e.Data != null) + finally { - lock(_buffer) + lock (_buffer) { - if (_buffer.Length != 0) - _buffer.Append(System.Environment.NewLine); - - _buffer.Append(e.Data); + _streamEnded = true; } } } + // Ошибка чтения источника отдается после уже накопленных данных, + // поэтому перебрасывается только при пустом буфере. + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private void ThrowIfPumpFailed() + { + if (_pumpError != null && _bufferIndex >= _buffer.Length) + ExceptionDispatchInfo.Capture(_pumpError).Throw(); + } + + /// + /// Дождаться, пока весь вывод процесса будет перекачан в буфер. + /// Вызывается после Process.WaitForExit(), чтобы Прочитать() гарантированно + /// видел хвост вывода (аналог гарантии WaitForExit() для событийного чтения). + /// + internal void WaitSourceDrained() + { + _pumpTask?.Wait(); + } + public override int Peek() { lock (_buffer) { if (_bufferIndex >= _buffer.Length) + { + ThrowIfPumpFailed(); return -1; // no data + } - return _buffer[_bufferIndex]; + return _buffer[_bufferIndex]; } } @@ -98,7 +133,11 @@ public override int Read() { lock (_buffer) { - return ReadInternal(); + int ch = ReadInternal(); + if (ch == -1) + ThrowIfPumpFailed(); + + return ch; } } @@ -123,7 +162,7 @@ public override int Read(char[] destBuffer, int index, int count) if (destBuffer.Length - index < count) throw new ArgumentException("Invalid offset"); - + int n = 0; lock (_buffer) { @@ -133,42 +172,73 @@ public override int Read(char[] destBuffer, int index, int count) if (ch == -1) break; destBuffer[index + n++] = (char)ch; - } while (n < count); + } while (n < count); + + if (n == 0 && count > 0) + ThrowIfPumpFailed(); } return n; } + /// + /// Возвращает очередную строку, только когда она гарантированно полна: + /// ее терминатор уже в буфере либо поток источника закончился. + /// Для незавершенной строки возвращает null (данных пока нет) — + /// частично записанная процессом строка не может быть возвращена + /// ни как строка, ни по кускам. + /// public override string ReadLine() { - var sb = new StringBuilder(); lock (_buffer) { - while (true) + for (int i = _bufferIndex; i < _buffer.Length; i++) { - int ch = ReadInternal(); - if (ch == -1) break; - if (ch == '\r' || ch == '\n') + char ch = _buffer[i]; + + if (ch == '\n') + return ConsumeLine(i, i + 1); + + if (ch == '\r') { - if (ch == '\r' && Peek() == '\n') Read(); - return sb.ToString(); + if (i + 1 < _buffer.Length) + return ConsumeLine(i, _buffer[i + 1] == '\n' ? i + 2 : i + 1); + + if (_streamEnded) + return ConsumeLine(i, i + 1); + + // '\r' — последний символ буфера, а поток еще жив: + // парный '\n' может быть в пути, ждем следующей порции + return null; } - sb.Append((char)ch); - } + } + + // терминатора нет; после конца потока остаток буфера — последняя строка + if (_streamEnded && _bufferIndex < _buffer.Length) + return ConsumeLine(_buffer.Length, _buffer.Length); + + ThrowIfPumpFailed(); + return null; } - if (sb.Length > 0) - return sb.ToString(); + } - return null; + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private string ConsumeLine(int lineEnd, int nextPosition) + { + var line = _buffer.ToString(_bufferIndex, lineEnd - _bufferIndex); + _bufferIndex = nextPosition; + return line; } public override string ReadToEnd() { lock (_buffer) { - string data = base.ReadToEnd(); + ThrowIfPumpFailed(); + + string data = _buffer.ToString(_bufferIndex, _buffer.Length - _bufferIndex); ResetBuffer(); - return data; + return data; } } @@ -182,7 +252,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - StopReading(); + _stopRequested = true; } base.Dispose(disposing); diff --git a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs new file mode 100644 index 000000000..fa2d3bc10 --- /dev/null +++ b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs @@ -0,0 +1,233 @@ +/*---------------------------------------------------------- +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.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Xunit; +using FluentAssertions; +using OneScript.StandardLibrary.Processes; + +namespace OneScript.StandardLibrary.Tests +{ + public class ProcessOutputWrapperTest : IDisposable + { + // Источник, отдающий текст порциями, как пайп работающего процесса: + // Read блокируется до появления следующей порции, Complete() дает EOF. + private class ChunkedReader : TextReader + { + private readonly BlockingCollection _chunks = new BlockingCollection(); + private string _current = string.Empty; + private int _pos; + private Exception _error; + + public void Push(string chunk) => _chunks.Add(chunk); + + public void Complete() => _chunks.CompleteAdding(); + + public void Fail(Exception error) + { + _error = error; + _chunks.CompleteAdding(); + } + + public override int Read(char[] buffer, int index, int count) + { + while (_pos >= _current.Length) + { + if (!_chunks.TryTake(out _current, Timeout.Infinite)) + { + if (_error != null) + throw _error; + + return 0; // EOF + } + + _pos = 0; + } + + int n = Math.Min(count, _current.Length - _pos); + _current.CopyTo(_pos, buffer, index, n); + _pos += n; + return n; + } + } + + private readonly ChunkedReader _source = new ChunkedReader(); + private readonly ProcessOutputWrapper _wrapper; + + public ProcessOutputWrapperTest() + { + _wrapper = new ProcessOutputWrapper(_source); + _wrapper.StartReading(); + } + + public void Dispose() + { + _source.Complete(); + _wrapper.Dispose(); + } + + // Перекачка фоновая, ждем видимого эффекта от отданной порции + private T Eventually(Func read, Func arrived) + { + var sw = Stopwatch.StartNew(); + while (true) + { + var value = read(); + if (arrived(value) || sw.ElapsedMilliseconds > 5000) + return value; + + Thread.Sleep(1); + } + } + + private string EventuallyReadLine() => Eventually(() => _wrapper.ReadLine(), line => line != null); + + [Fact] + public void ReadLine_ReturnsLinesInOrder() + { + _source.Push("L1\nL2\nL3\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + EventuallyReadLine().Should().Be("L3"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Гонка из issue #1726: читатель вычерпал буфер по границе строки, + // затем поступила следующая порция. Лишняя пустая строка не появляется. + [Fact] + public void ReadLine_DoesNotInsertEmptyLine_WhenBufferDrainedBetweenWrites() + { + _source.Push("L1\nL2\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("L3\n"); + + EventuallyReadLine().Should().Be("L3"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Незавершенная строка не возвращается ни целиком, ни по кускам, + // пока не придет ее терминатор + [Fact] + public void ReadLine_DoesNotTearLine_SplitBetweenWrites() + { + _source.Push("L1\nПол"); + + EventuallyReadLine().Should().Be("L1"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("овина\n"); + + EventuallyReadLine().Should().Be("Половина"); + } + + // CRLF, разорванный между порциями, не дает ни фантомной пустой строки, + // ни '\r' в теле строки + [Fact] + public void ReadLine_HandlesCrLfSplitBetweenWrites() + { + _source.Push("L1\r"); + + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("\nL2\r\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + [Fact] + public void ReadLine_PreservesRealEmptyLines() + { + _source.Push("L1\n\nL2\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be(""); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Пустая последняя строка вывода больше не теряется + [Fact] + public void ReadLine_PreservesTrailingEmptyLine() + { + _source.Push("L1\n\n"); + _source.Complete(); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be(""); + _wrapper.ReadLine().Should().BeNull(); + } + + // Вывод, не завершенный переводом строки, возвращается по концу потока + [Fact] + public void ReadLine_ReturnsUnterminatedTail_AfterStreamEnd() + { + _source.Push("L1\nL2"); + + EventuallyReadLine().Should().Be("L1"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Complete(); + + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Прочитать() возвращает подлинные символы вывода процесса, + // без синтеза и без подрезки + [Fact] + public void ReadToEnd_ReturnsRawOutput() + { + _source.Push("L1\nL2\n"); + + Eventually(() => _wrapper.Peek(), c => c != -1); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.ReadToEnd().Should().Be("L2\n"); + } + + // Ошибка чтения источника не глотается: она перебрасывается читателю, + // но только после того, как он вычитает накопленные данные + [Fact] + public void SourceFailure_SurfacesToReader_AfterBufferedDataIsConsumed() + { + _source.Push("L1\n"); + _source.Fail(new IOException("pipe failure")); + + EventuallyReadLine().Should().Be("L1"); + + var error = Eventually(ReadLineError, e => e != null); + error.Should().BeOfType().Which.Message.Should().Be("pipe failure"); + + // ошибка не одноразовая: последующие обращения тоже ее видят + ReadLineError().Should().BeOfType(); + } + + private Exception ReadLineError() + { + try + { + _wrapper.ReadLine(); + return null; + } + catch (Exception e) + { + return e; + } + } + } +} diff --git a/tests/process.os b/tests/process.os index f8db0b823..722ef93e5 100644 --- a/tests/process.os +++ b/tests/process.os @@ -11,6 +11,7 @@ Если Найти(СИ.ВерсияОС, "Windows") > 0 Тогда ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводOscriptСразу"); ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводOscriptПострочно"); + ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводБезКонцевогоПереводаСтроки"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоМетодПрочитатьНаПустомПотокеВернетНеопределено"); ВсеТесты.Добавить("ТестДолжен_ЗаписатьВоВходнойПотокПроцесса"); ВсеТесты.Добавить("ТестДолжен_НайтиПроцессПоИмени"); @@ -33,11 +34,12 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + + // Прочитать() возвращает вывод как есть, включая завершающий перевод строки Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); - + КонецПроцедуры Процедура ТестДолжен_ПрочитатьВыводOscriptПострочно() Экспорт @@ -59,13 +61,29 @@ Стр = Стр + СтрокаПотока + " |"; КонецЦикла; - - Стр = СокрП(Стр); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), НормализоватьПереводыСтрок(Стр)); КонецПроцедуры +Процедура ТестДолжен_ПрочитатьВыводБезКонцевогоПереводаСтроки() Экспорт + + ИмяСкрипта = юТест.ИмяВременногоФайла("os"); + ЗаписьТекста = Новый ЗаписьТекста(ИмяСкрипта, КодировкаТекста.UTF8); + ЗаписьТекста.ЗаписатьСтроку("Консоль.ВывестиСтроку(""line1"");"); + ЗаписьТекста.Записать("Консоль.Вывести(""line2"");"); + ЗаписьТекста.Закрыть(); + + Процесс = СоздатьПроцесс(ПутьОСкрипт() + " """ + ИмяСкрипта + """",, Истина); + Процесс.Запустить(); + Процесс.ОжидатьЗавершения(); + + Стр = НормализоватьПереводыСтрок(Процесс.ПотокВывода.Прочитать()); + + юТест.ПроверитьРавенство("line1" + Символы.ПС + "line2", Стр); + +КонецПроцедуры + Процедура ТестДолжен_ЗаписатьВоВходнойПотокПроцесса() Экспорт КодировкаПоУмолчанию = Консоль.КодировкаВходногоПотока; @@ -88,7 +106,8 @@ Эталон = НормализоватьПереводыСтрок("1 |2 - |3"); + |3 + |"); юТест.ПроверитьРавенство(Эталон, НормализоватьПереводыСтрок(Вывод)); КонецПроцедуры @@ -168,8 +187,9 @@ | |CGI Mode: | oscript.exe -cgi [script_arguments...] - | Runs as CGI application under HTTP-server."; - + | Runs as CGI application under HTTP-server. + |"; + Возврат НормализоватьПереводыСтрок(Текст); КонецФункции @@ -235,9 +255,9 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); КонецПроцедуры @@ -250,9 +270,9 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); КонецПроцедуры diff --git a/tests/showversion.os b/tests/showversion.os index 4beaf301c..f71a71554 100644 --- a/tests/showversion.os +++ b/tests/showversion.os @@ -34,9 +34,10 @@ Процесс.Запустить(); Процесс.ОжидатьЗавершения(); - ДанныеВывода = Процесс.ПотокВывода.Прочитать(); + // Прочитать() возвращает вывод как есть, включая завершающий перевод строки + ДанныеВывода = СокрЛП(Процесс.ПотокВывода.Прочитать()); - юТест.ПроверитьРавенство(ДанныеВывода, СистемнаяИнформация.Версия, "Версия вывода не совпадает с системной информацией!"); + юТест.ПроверитьРавенство(СистемнаяИнформация.Версия, ДанныеВывода, "Версия вывода не совпадает с системной информацией!"); КонецЦикла; From 204e20abe22d89a43c02a38facbbcb22c835b710 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 11:12:47 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Завершен не сообщает о завершении процесса, пока фоновое чтение не добрало хвост вывода из пайпа: цикл "Пока НЕ Завершен ИЛИ ЕстьДанные" больше не может завершиться раньше, чем вывод станет доступен. - Вычитанный префикс буфера периодически освобождается: вывод долгоживущего процесса не накапливается в памяти при аккуратном читателе. - После Закрыть() пайп продолжает дренироваться до конца потока с отбрасыванием данных: процесс не блокируется на записи в переполненный пайп, как это обеспечивал и событийный API. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013YSVPuji9Ge244GRqfawP9 --- .../Processes/ProcessContext.cs | 9 +++- .../Processes/ProcessOutputWrapper.cs | 45 +++++++++++++++++- .../ProcessOutputWrapperTest.cs | 46 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs index d3d43d6b3..a5948a1bf 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs @@ -150,7 +150,14 @@ public bool HasExited { get { - return _p.HasExited; + if (!_p.HasExited) + return false; + + // Процесс завершился, но фоновое чтение могло еще не добрать + // хвост вывода из пайпа: цикл "Пока НЕ Завершен ИЛИ ЕстьДанные" + // не должен завершиться раньше, чем вывод станет доступен + return (_stdOutWrapper?.IsDrained ?? true) + && (_stdErrWrapper?.IsDrained ?? true); } } diff --git a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs index 8a88fa542..2a9d64143 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs @@ -27,6 +27,8 @@ namespace OneScript.StandardLibrary.Processes /// class ProcessOutputWrapper : TextReader { + private const int CompactionThreshold = 4096; + private readonly TextReader _source; private readonly StringBuilder _buffer = new StringBuilder(4096); @@ -62,12 +64,18 @@ private async Task PumpAsync(TextReader reader) var chunk = new char[4096]; try { - while (!_stopRequested) + while (true) { int read = await reader.ReadAsync(chunk, 0, chunk.Length).ConfigureAwait(false); if (read == 0) break; + // После закрытия читателя данные никому не нужны, но пайп + // продолжает дренироваться до конца потока: иначе процесс, + // заполнив пайп, навсегда заблокируется на записи + if (_stopRequested) + continue; + lock (_buffer) { _buffer.Append(chunk, 0, read); @@ -115,6 +123,24 @@ internal void WaitSourceDrained() _pumpTask?.Wait(); } + /// + /// Источник дочитан до конца: после завершения процесса хвост его + /// вывода гарантированно доступен читателям. + /// + internal bool IsDrained => _pumpTask == null || _pumpTask.IsCompleted; + + // Для тестов: физический размер внутреннего буфера + internal int InternalBufferSize + { + get + { + lock (_buffer) + { + return _buffer.Length; + } + } + } + public override int Peek() { lock (_buffer) @@ -137,10 +163,24 @@ public override int Read() if (ch == -1) ThrowIfPumpFailed(); + CompactBuffer(); return ch; } } + // Вычитанный префикс буфера периодически удаляется, иначе вывод + // долгоживущего процесса накапливается в памяти даже при аккуратном + // читателе. + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private void CompactBuffer() + { + if (_bufferIndex >= CompactionThreshold) + { + _buffer.Remove(0, _bufferIndex); + _bufferIndex = 0; + } + } + // неблокирующий доступ к буферу. // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. private int ReadInternal() @@ -176,6 +216,8 @@ public override int Read(char[] destBuffer, int index, int count) if (n == 0 && count > 0) ThrowIfPumpFailed(); + + CompactBuffer(); } return n; @@ -227,6 +269,7 @@ private string ConsumeLine(int lineEnd, int nextPosition) { var line = _buffer.ToString(_bufferIndex, lineEnd - _bufferIndex); _bufferIndex = nextPosition; + CompactBuffer(); return line; } diff --git a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs index fa2d3bc10..a91360493 100644 --- a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs +++ b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs @@ -37,6 +37,8 @@ public void Fail(Exception error) _chunks.CompleteAdding(); } + public bool AllTaken => _chunks.Count == 0; + public override int Read(char[] buffer, int index, int count) { while (_pos >= _current.Length) @@ -229,5 +231,49 @@ private Exception ReadLineError() return e; } } + + // После закрытия читателя источник продолжает дренироваться + // (иначе процесс заблокируется на записи в переполненный пайп), + // но данные больше не накапливаются + [Fact] + public void AfterDispose_SourceIsStillDrained_ButDataIsDiscarded() + { + _source.Push("L1\n"); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.Dispose(); + _source.Push("L2\n"); + + Eventually(() => _source.AllTaken, taken => taken).Should().BeTrue(); + _wrapper.ReadLine().Should().BeNull(); + } + + // Вычитанный префикс буфера освобождается: вывод долгоживущего + // процесса не накапливается в памяти при аккуратном читателе + [Fact] + public void ConsumedPrefix_IsCompacted() + { + var line = new string('x', 1000); + for (int i = 0; i < 20; i++) + { + _source.Push(line + "\n"); + EventuallyReadLine().Should().Be(line); + } + + _wrapper.InternalBufferSize.Should().BeLessThan(2 * 4096); + } + + [Fact] + public void IsDrained_BecomesTrue_OnlyAfterSourceEnd() + { + _source.Push("L1\n"); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.IsDrained.Should().BeFalse(); + + _source.Complete(); + + Eventually(() => _wrapper.IsDrained, drained => drained).Should().BeTrue(); + } } } From 8c8c28bf5bc79ec6730315432b16bda05530fa52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:05:03 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E:=20=D1=81=D0=BA=D0=B0=D0=BD=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ПрочитатьСтроку() при опросе незавершенной строки больше не сканирует накопленный буфер заново: курсор помнит проверенную часть без терминаторов, и каждый символ сканируется один раз. - Решение о приеме или отбрасывании порции данных принимается атомарно с остановкой чтения: после возврата из Dispose данные в буфер гарантированно не попадают. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013YSVPuji9Ge244GRqfawP9 --- .../Processes/ProcessOutputWrapper.cs | 31 +++++++++++++------ .../ProcessOutputWrapperTest.cs | 17 ++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs index 2a9d64143..7b800e6a8 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs @@ -34,6 +34,11 @@ class ProcessOutputWrapper : TextReader private int _bufferIndex = 0; + // Позиция, до которой ReadLine уже искал терминатор и не нашел: + // диапазон [_bufferIndex, _lineScanIndex) заведомо без терминаторов, + // повторные вызовы сканируют только новые символы, а не буфер целиком + private int _lineScanIndex = 0; + // Пишутся пампом и читаются читателями только под lock(_buffer) private bool _streamEnded; private Exception _pumpError; @@ -70,15 +75,13 @@ private async Task PumpAsync(TextReader reader) if (read == 0) break; - // После закрытия читателя данные никому не нужны, но пайп - // продолжает дренироваться до конца потока: иначе процесс, - // заполнив пайп, навсегда заблокируется на записи - if (_stopRequested) - continue; - lock (_buffer) { - _buffer.Append(chunk, 0, read); + // После закрытия читателя данные никому не нужны, но пайп + // продолжает дренироваться до конца потока: иначе процесс, + // заполнив пайп, навсегда заблокируется на записи + if (!_stopRequested) + _buffer.Append(chunk, 0, read); } } } @@ -177,6 +180,7 @@ private void CompactBuffer() if (_bufferIndex >= CompactionThreshold) { _buffer.Remove(0, _bufferIndex); + _lineScanIndex = Math.Max(0, _lineScanIndex - _bufferIndex); _bufferIndex = 0; } } @@ -234,7 +238,7 @@ public override string ReadLine() { lock (_buffer) { - for (int i = _bufferIndex; i < _buffer.Length; i++) + for (int i = Math.Max(_lineScanIndex, _bufferIndex); i < _buffer.Length; i++) { char ch = _buffer[i]; @@ -251,10 +255,14 @@ public override string ReadLine() // '\r' — последний символ буфера, а поток еще жив: // парный '\n' может быть в пути, ждем следующей порции + // (сам '\r' при этом остается несканированным) + _lineScanIndex = i; return null; } } + _lineScanIndex = _buffer.Length; + // терминатора нет; после конца потока остаток буфера — последняя строка if (_streamEnded && _bufferIndex < _buffer.Length) return ConsumeLine(_buffer.Length, _buffer.Length); @@ -269,6 +277,7 @@ private string ConsumeLine(int lineEnd, int nextPosition) { var line = _buffer.ToString(_bufferIndex, lineEnd - _bufferIndex); _bufferIndex = nextPosition; + _lineScanIndex = nextPosition; CompactBuffer(); return line; } @@ -289,13 +298,17 @@ private void ResetBuffer() { _buffer.Clear(); _bufferIndex = 0; + _lineScanIndex = 0; } protected override void Dispose(bool disposing) { if (disposing) { - _stopRequested = true; + lock (_buffer) + { + _stopRequested = true; + } } base.Dispose(disposing); diff --git a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs index a91360493..64b15a4f5 100644 --- a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs +++ b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs @@ -135,6 +135,23 @@ public void ReadLine_DoesNotTearLine_SplitBetweenWrites() EventuallyReadLine().Should().Be("Половина"); } + // Длинная строка, приходящая многими порциями: промежуточные опросы + // возвращают null, итоговая строка собирается целиком + [Fact] + public void ReadLine_AssemblesLine_FromManyChunks() + { + _source.Push("aa"); + Eventually(() => _wrapper.Peek(), c => c != -1); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("bb"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("cc\n"); + EventuallyReadLine().Should().Be("aabbcc"); + _wrapper.ReadLine().Should().BeNull(); + } + // CRLF, разорванный между порциями, не дает ни фантомной пустой строки, // ни '\r' в теле строки [Fact]