diff --git a/Core/Foundation/CMakeLists.txt b/Core/Foundation/CMakeLists.txt index 04028562..f85322e7 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -8,6 +8,7 @@ set(SOURCES "Source/DelayedTaskScheduler.h" "Source/PerfTrace.cpp" "Source/StandardStreamLogger.cpp" + "Source/StandardStreamLoggerLines.h" "Source/StandardStreamLoggerPlatform.h") # .inl bodies are #include'd by the platform TUs (not separate translation units). diff --git a/Core/Foundation/Include/Babylon/StandardStreamLogger.h b/Core/Foundation/Include/Babylon/StandardStreamLogger.h index 765fb149..cc21188e 100644 --- a/Core/Foundation/Include/Babylon/StandardStreamLogger.h +++ b/Core/Foundation/Include/Babylon/StandardStreamLogger.h @@ -11,6 +11,15 @@ namespace Babylon::StandardStreamLogger * forwards to OutputDebugString while preserving the original stream destination. * Other Unix platforms already expose standard streams and leave them unchanged. * + * Private descriptors are non-inheritable, and the original standard-stream + * inheritance flags are preserved. Applications must serialize concurrent + * child-process creation with Start()/Stop(): redirection and flag restoration + * are not a single atomic operation. Apple additionally lacks atomic + * close-on-exec pipe creation. + * + * Platform diagnostics split long lines to fit their sink's size limit. + * Chunking does not affect the tee to the original stream destination. + * * Returns false if a platform stream could not be redirected. Repeated calls are * idempotent. */ diff --git a/Core/Foundation/Source/StandardStreamLoggerLines.h b/Core/Foundation/Source/StandardStreamLoggerLines.h new file mode 100644 index 00000000..a66f9288 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLoggerLines.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +namespace Babylon::StandardStreamLogger::Detail +{ + template + void EmitPendingLines(std::string& pending, size_t maxLineSize, bool flush, Emit&& emit) + { + assert(maxLineSize >= 4); + size_t start{}; + while (start < pending.size()) + { + const size_t newline = pending.find('\n', start); + const size_t end = newline == std::string::npos ? pending.size() : newline; + size_t size = end - start; + if (size != 0 && pending[end - 1] == '\r') + { + if (newline != std::string::npos || flush) + { + --size; + } + else if (size == maxLineSize + 1) + { + // The next read may complete CRLF after an exactly full line. + break; + } + } + if (size > maxLineSize) + { + size = maxLineSize; + while (size != 0 && (static_cast(pending[start + size]) & 0xC0) == 0x80) + { + --size; + } + // Standard streams can contain invalid UTF-8; still make progress. + if (size == 0) + { + size = maxLineSize; + } + emit(pending.substr(start, size)); + start += size; + } + else if (newline != std::string::npos || flush) + { + emit(pending.substr(start, size)); + start = end + (newline != std::string::npos ? 1 : 0); + } + else + { + break; + } + } + pending.erase(0, start); + } +} diff --git a/Core/Foundation/Source/StandardStreamLogger_Android.cpp b/Core/Foundation/Source/StandardStreamLogger_Android.cpp index ef1d0052..2ade6317 100644 --- a/Core/Foundation/Source/StandardStreamLogger_Android.cpp +++ b/Core/Foundation/Source/StandardStreamLogger_Android.cpp @@ -13,6 +13,11 @@ namespace // POSIX fd helpers (dup/pipe/CLOEXEC/devnull); sink is OsWritePlatform below. #include "StandardStreamLogger_PosixOps.inl" + size_t OsMaxPlatformLineSize(bool /*isError*/) + { + return 3800; + } + void OsWritePlatform(bool isError, const std::string& line) { const int priority = isError ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO; diff --git a/Core/Foundation/Source/StandardStreamLogger_Apple.cpp b/Core/Foundation/Source/StandardStreamLogger_Apple.cpp index 5c514d88..0551b870 100644 --- a/Core/Foundation/Source/StandardStreamLogger_Apple.cpp +++ b/Core/Foundation/Source/StandardStreamLogger_Apple.cpp @@ -13,6 +13,12 @@ namespace // POSIX fd helpers (dup/pipe/CLOEXEC/devnull); sink is OsWritePlatform below. #include "StandardStreamLogger_PosixOps.inl" + size_t OsMaxPlatformLineSize(bool isError) + { + // Reserve the terminator within os_log's persisted dynamic-content budget. + return isError ? 255 : 1023; + } + void OsWritePlatform(bool isError, const std::string& line) { const os_log_type_t type = isError ? OS_LOG_TYPE_ERROR : OS_LOG_TYPE_DEFAULT; diff --git a/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl b/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl index ef215102..3362ec37 100644 --- a/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl +++ b/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl @@ -3,11 +3,13 @@ struct ChannelPlatformState { + int OriginalDescriptorFlags{}; + bool OriginalDescriptorOpen{}; }; int OsDuplicate(int fd) { - return ::dup(fd); + return ::fcntl(fd, F_DUPFD_CLOEXEC, 0); } int OsDuplicateTo(int source, int target) @@ -32,6 +34,11 @@ int64_t OsWrite(int fd, const void* data, size_t size) int OsCreatePipe(int fds[2]) { +#if defined(__ANDROID__) + return ::pipe2(fds, O_CLOEXEC); +#else + // The portable fallback requires callers to serialize Start() with + // fork/exec to avoid inheritance between pipe() and fcntl(). if (::pipe(fds) != 0) { return -1; @@ -48,11 +55,12 @@ int OsCreatePipe(int fds[2]) return -1; } return 0; +#endif } bool OsOccupyTarget(int target) { - const int nullFd = ::open("/dev/null", O_WRONLY); + const int nullFd = ::open("/dev/null", O_WRONLY | O_CLOEXEC); if (nullFd < 0) { return false; @@ -67,17 +75,20 @@ bool OsOccupyTarget(int target) return duplicated; } -bool OsOnStartChannel(ChannelPlatformState&, int, bool) +bool OsOnStartChannel(ChannelPlatformState& state, int target, bool) { - return true; + state.OriginalDescriptorFlags = ::fcntl(target, F_GETFD); + state.OriginalDescriptorOpen = state.OriginalDescriptorFlags >= 0; + return state.OriginalDescriptorOpen || errno == EBADF; } -bool OsOnRedirected(ChannelPlatformState&, int) +bool OsOnRedirected(ChannelPlatformState& state, int target) { - return true; + const int flags = state.OriginalDescriptorOpen ? state.OriginalDescriptorFlags : FD_CLOEXEC; + return ::fcntl(target, F_SETFD, flags) == 0; } -bool OsOnRestore(ChannelPlatformState&, int) +bool OsOnRestore(ChannelPlatformState& state, int target) { - return true; + return !state.OriginalDescriptorOpen || ::fcntl(target, F_SETFD, state.OriginalDescriptorFlags) == 0; } \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger_Shared.inl b/Core/Foundation/Source/StandardStreamLogger_Shared.inl index db765b1c..d3f42744 100644 --- a/Core/Foundation/Source/StandardStreamLogger_Shared.inl +++ b/Core/Foundation/Source/StandardStreamLogger_Shared.inl @@ -9,11 +9,14 @@ // int64_t OsWrite(int fd, const void* data, size_t size); // int OsCreatePipe(int fds[2]); // bool OsOccupyTarget(int target); +// size_t OsMaxPlatformLineSize(bool isError); // void OsWritePlatform(bool isError, const std::string& line); // bool OsOnStartChannel(ChannelPlatformState& state, int target, bool isError); // bool OsOnRedirected(ChannelPlatformState& state, int target); // bool OsOnRestore(ChannelPlatformState& state, int target); +#include "StandardStreamLoggerLines.h" + #include #include #include @@ -70,20 +73,12 @@ namespace return true; } - void EmitLine(Stream stream, std::string line) - { - if (!line.empty() && line.back() == '\r') - { - line.pop_back(); - } - OsWritePlatform(stream == Stream::Error, line); - } - void Drain(int readFd, int originalFd, Stream stream) { - // Cap mirrored lines below typical platform limits (~4 KiB for - // OutputDebugStringA / logcat / os_log). Leave headroom under 4096. - constexpr size_t MAX_PLATFORM_LINE_SIZE{3800}; + const size_t maxLineSize = OsMaxPlatformLineSize(stream == Stream::Error); + const auto emit = [stream](const std::string& line) { + OsWritePlatform(stream == Stream::Error, line); + }; std::array buffer{}; std::string pending{}; @@ -111,37 +106,10 @@ namespace pending.append(buffer.data(), size); - // Consume complete lines via a start index so we only memmove once - // per read batch instead of on every newline. - size_t start = 0; - for (;;) - { - const size_t newline = pending.find('\n', start); - if (newline != std::string::npos) - { - EmitLine(stream, pending.substr(start, newline - start)); - start = newline + 1; - } - else if (pending.size() - start >= MAX_PLATFORM_LINE_SIZE) - { - EmitLine(stream, pending.substr(start, MAX_PLATFORM_LINE_SIZE)); - start += MAX_PLATFORM_LINE_SIZE; - } - else - { - break; - } - } - if (start != 0) - { - pending.erase(0, start); - } + Babylon::StandardStreamLogger::Detail::EmitPendingLines(pending, maxLineSize, false, emit); } - if (!pending.empty()) - { - EmitLine(stream, std::move(pending)); - } + Babylon::StandardStreamLogger::Detail::EmitPendingLines(pending, maxLineSize, true, emit); (void)OsClose(readFd); if (originalFd >= 0) { diff --git a/Core/Foundation/Source/StandardStreamLogger_Windows.cpp b/Core/Foundation/Source/StandardStreamLogger_Windows.cpp index e2ff1559..a44a7f54 100644 --- a/Core/Foundation/Source/StandardStreamLogger_Windows.cpp +++ b/Core/Foundation/Source/StandardStreamLogger_Windows.cpp @@ -11,6 +11,10 @@ namespace { + constexpr intptr_t NO_CONSOLE_FILENO{-2}; + // Some Windows SDKs hide HANDLE_FLAG_INHERIT from the app partition. + constexpr DWORD HANDLE_INHERIT_FLAG{0x00000001}; + void IgnoreInvalidParameter( const wchar_t*, const wchar_t*, @@ -25,14 +29,100 @@ namespace { DWORD StandardHandle{}; HANDLE OriginalHandle{INVALID_HANDLE_VALUE}; + DWORD OriginalDescriptorHandleFlags{}; + bool OriginalDescriptorOpen{}; bool OriginalHandleUsesTarget{}; }; + void SetErrnoFromWin32Error(DWORD error) + { + _doserrno = error; + switch (error) + { + case ERROR_INVALID_HANDLE: + errno = EBADF; + break; + case ERROR_TOO_MANY_OPEN_FILES: + errno = EMFILE; + break; + case ERROR_NOT_ENOUGH_MEMORY: + case ERROR_OUTOFMEMORY: + errno = ENOMEM; + break; + case ERROR_ACCESS_DENIED: + errno = EACCES; + break; + case ERROR_INVALID_PARAMETER: + errno = EINVAL; + break; + case ERROR_BROKEN_PIPE: + errno = EPIPE; + break; + default: + errno = EIO; + break; + } + } + + intptr_t GetOsHandle(int fd) + { + const auto previousHandler = ::_set_thread_local_invalid_parameter_handler(IgnoreInvalidParameter); + const intptr_t handle = ::_get_osfhandle(fd); + (void)::_set_thread_local_invalid_parameter_handler(previousHandler); + return handle; + } + + bool SetDescriptorInheritance(int fd, bool inherit) + { + const intptr_t handle = GetOsHandle(fd); + if (handle == -1) + { + return false; + } + if (!::SetHandleInformation( + reinterpret_cast(handle), + HANDLE_INHERIT_FLAG, + inherit ? HANDLE_INHERIT_FLAG : 0)) + { + SetErrnoFromWin32Error(::GetLastError()); + return false; + } + return true; + } + int OsDuplicate(int fd) { + const intptr_t sourceHandle = GetOsHandle(fd); + if (sourceHandle == -1) + { + return -1; + } + if (sourceHandle == NO_CONSOLE_FILENO) + { + errno = EBADF; + _doserrno = 0; + return -1; + } + + // _dup preserves the source descriptor's complete CRT state (including + // text mode), unlike rebuilding it with _open_osfhandle. const auto previousHandler = ::_set_thread_local_invalid_parameter_handler(IgnoreInvalidParameter); const int duplicated = ::_dup(fd); (void)::_set_thread_local_invalid_parameter_handler(previousHandler); + + if (duplicated < 0) + { + return -1; + } + if (!SetDescriptorInheritance(duplicated, false)) + { + const int error = errno; + const unsigned long dosError = _doserrno; + (void)::_close(duplicated); + errno = error; + _doserrno = dosError; + return -1; + } return duplicated; } @@ -68,10 +158,11 @@ namespace HANDLE writeHandle{INVALID_HANDLE_VALUE}; if (!::CreatePipe(&readHandle, &writeHandle, &attributes, 4096)) { + SetErrnoFromWin32Error(::GetLastError()); return -1; } - fds[0] = ::_open_osfhandle(reinterpret_cast(readHandle), _O_BINARY); + fds[0] = ::_open_osfhandle(reinterpret_cast(readHandle), _O_BINARY | _O_NOINHERIT); if (fds[0] < 0) { (void)::CloseHandle(readHandle); @@ -79,7 +170,7 @@ namespace return -1; } - fds[1] = ::_open_osfhandle(reinterpret_cast(writeHandle), _O_BINARY); + fds[1] = ::_open_osfhandle(reinterpret_cast(writeHandle), _O_BINARY | _O_NOINHERIT); if (fds[1] < 0) { (void)::_close(fds[0]); @@ -94,8 +185,11 @@ namespace { // Prefer the secure CRT form; UWP treats the deprecated _open as an error. int nullFd{-1}; - if (::_sopen_s(&nullFd, "NUL", _O_WRONLY | _O_BINARY, _SH_DENYNO, 0) != 0) + const errno_t openError = + ::_sopen_s(&nullFd, "NUL", _O_WRONLY | _O_BINARY | _O_NOINHERIT, _SH_DENYNO, 0); + if (openError != 0) { + errno = openError; return false; } if (nullFd == target) @@ -103,11 +197,30 @@ namespace return true; } - const bool duplicated = OsDuplicateTo(nullFd, target) == 0; + const bool targetDuplicated = OsDuplicateTo(nullFd, target) == 0; + const bool duplicated = + targetDuplicated && + SetDescriptorInheritance(target, false); + const int error = errno; + const unsigned long dosError = _doserrno; (void)OsClose(nullFd); + if (!duplicated) + { + if (targetDuplicated) + { + (void)OsClose(target); + } + errno = error; + _doserrno = dosError; + } return duplicated; } + size_t OsMaxPlatformLineSize(bool /*isError*/) + { + return 3800; + } + void OsWritePlatform(bool /*isError*/, const std::string& line) { std::string output{line}; @@ -115,21 +228,30 @@ namespace ::OutputDebugStringA(output.c_str()); } - intptr_t GetOsHandle(int fd) - { - const auto previousHandler = ::_set_thread_local_invalid_parameter_handler(IgnoreInvalidParameter); - const intptr_t handle = ::_get_osfhandle(fd); - (void)::_set_thread_local_invalid_parameter_handler(previousHandler); - return handle; - } - bool OsOnStartChannel(ChannelPlatformState& state, int target, bool isError) { state.StandardHandle = isError ? STD_ERROR_HANDLE : STD_OUTPUT_HANDLE; state.OriginalHandle = ::GetStdHandle(state.StandardHandle); const intptr_t targetHandle = GetOsHandle(target); + if (targetHandle == -1) + { + return errno == EBADF; + } + if (targetHandle == NO_CONSOLE_FILENO) + { + return true; + } + + if (!::GetHandleInformation( + reinterpret_cast(targetHandle), + &state.OriginalDescriptorHandleFlags)) + { + SetErrnoFromWin32Error(::GetLastError()); + return false; + } + + state.OriginalDescriptorOpen = true; state.OriginalHandleUsesTarget = - targetHandle != -1 && state.OriginalHandle != nullptr && state.OriginalHandle != INVALID_HANDLE_VALUE && state.OriginalHandle == reinterpret_cast(targetHandle); @@ -143,22 +265,48 @@ namespace { return false; } - return ::SetStdHandle(state.StandardHandle, reinterpret_cast(pipeHandle)) != FALSE; + + const bool inherit = + state.OriginalDescriptorOpen && + (state.OriginalDescriptorHandleFlags & HANDLE_INHERIT_FLAG) != 0; + if (!SetDescriptorInheritance(target, inherit)) + { + return false; + } + if (!::SetStdHandle(state.StandardHandle, reinterpret_cast(pipeHandle))) + { + SetErrnoFromWin32Error(::GetLastError()); + return false; + } + return true; } bool OsOnRestore(ChannelPlatformState& state, int target) { HANDLE handle = state.OriginalHandle; - if (state.OriginalHandleUsesTarget) + bool restored{true}; + if (state.OriginalDescriptorOpen) { const intptr_t restoredHandle = GetOsHandle(target); if (restoredHandle == -1) { return false; } - handle = reinterpret_cast(restoredHandle); + + const bool inherit = + (state.OriginalDescriptorHandleFlags & HANDLE_INHERIT_FLAG) != 0; + restored = SetDescriptorInheritance(target, inherit); + if (state.OriginalHandleUsesTarget) + { + handle = reinterpret_cast(restoredHandle); + } + } + if (!::SetStdHandle(state.StandardHandle, handle)) + { + SetErrnoFromWin32Error(::GetLastError()); + return false; } - return ::SetStdHandle(state.StandardHandle, handle) != FALSE; + return restored; } } diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 2e31e0fb..9b7bba2d 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -20,6 +20,8 @@ add_library(UnitTestsJNI SHARED JNI.cpp ${UNIT_TESTS_DIR}/Shared/DelayedTaskScheduler.cpp ${UNIT_TESTS_DIR}/Shared/StandardStreamLogger.cpp + ${UNIT_TESTS_DIR}/Shared/StandardStreamLoggerLines.cpp + ${UNIT_TESTS_DIR}/Shared/StandardStreamLoggerPosix.cpp ${UNIT_TESTS_DIR}/Shared/TimeoutDispatcher.cpp ${UNIT_TESTS_DIR}/Shared/Shared.h ${UNIT_TESTS_DIR}/Shared/Shared.cpp) diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index f3676d7d..14a23406 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -10,6 +10,8 @@ file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES "Shared/DelayedTaskScheduler.cpp" "Shared/StandardStreamLogger.cpp" + "Shared/StandardStreamLoggerLines.cpp" + "Shared/StandardStreamLoggerPosix.cpp" "Shared/TimeoutDispatcher.cpp" "Shared/Shared.cpp" "Shared/Shared.h") diff --git a/Tests/UnitTests/Shared/StandardStreamLogger.cpp b/Tests/UnitTests/Shared/StandardStreamLogger.cpp index 7f0c71e6..e4c42523 100644 --- a/Tests/UnitTests/Shared/StandardStreamLogger.cpp +++ b/Tests/UnitTests/Shared/StandardStreamLogger.cpp @@ -9,12 +9,16 @@ #include #include #else +#include #include #endif namespace { #if defined(_WIN32) + // HANDLE_FLAG_INHERIT's documented value; the SDK hides the macro from UWP. + constexpr DWORD InheritHandleFlag{0x00000001}; + int DuplicateFileDescriptor(int fd) { return ::_dup(fd); @@ -34,6 +38,22 @@ namespace { return ::_fileno(file); } + + int DescriptorInheritance(int fd) + { + DWORD flags{}; + const intptr_t handle = ::_get_osfhandle(fd); + return handle != -1 && ::GetHandleInformation(reinterpret_cast(handle), &flags) + ? (flags & InheritHandleFlag) != 0 + : -1; + } + + bool SetDescriptorInheritance(int fd, bool inherit) + { + const intptr_t handle = ::_get_osfhandle(fd); + return handle != -1 && ::SetHandleInformation( + reinterpret_cast(handle), InheritHandleFlag, inherit ? InheritHandleFlag : 0); + } #else int DuplicateFileDescriptor(int fd) { @@ -54,6 +74,18 @@ namespace { return ::fileno(file); } + + int DescriptorInheritance(int fd) + { + const int flags = ::fcntl(fd, F_GETFD); + return flags >= 0 ? (flags & FD_CLOEXEC) == 0 : -1; + } + + bool SetDescriptorInheritance(int fd, bool inherit) + { + const int flags = ::fcntl(fd, F_GETFD); + return flags >= 0 && ::fcntl(fd, F_SETFD, inherit ? flags & ~FD_CLOEXEC : flags | FD_CLOEXEC) == 0; + } #endif class StdoutCapture @@ -62,6 +94,7 @@ namespace StdoutCapture() { std::fflush(stdout); + m_originalInheritance = DescriptorInheritance(1); m_original = DuplicateFileDescriptor(1); #if defined(_WIN32) m_originalStdHandle = ::GetStdHandle(STD_OUTPUT_HANDLE); @@ -127,6 +160,10 @@ namespace { std::fflush(stdout); const bool restored = DuplicateFileDescriptorTo(m_original, 1) == 0; + if (restored && m_originalInheritance >= 0) + { + (void)SetDescriptorInheritance(1, m_originalInheritance != 0); + } (void)CloseFileDescriptor(m_original); m_original = -1; #if defined(_WIN32) @@ -154,6 +191,7 @@ namespace FILE* m_file{}; int m_original{-1}; + int m_originalInheritance{-1}; bool m_valid{}; #if defined(_WIN32) HANDLE m_originalStdHandle{INVALID_HANDLE_VALUE}; @@ -196,3 +234,57 @@ TEST(StandardStreamLogger, Lifecycle) EXPECT_TRUE(Babylon::StandardStreamLogger::Stop()); EXPECT_EQ(captured, "StandardStreamLogger stdout test"); } + +TEST(StandardStreamLogger, PreservesDescriptorInheritance) +{ + if (Babylon::StandardStreamLogger::IsStarted()) + { + GTEST_SKIP() << "The platform host already owns standard-stream forwarding."; + } + + for (const bool inherit : {false, true}) + { + StdoutCapture capture{}; + if (!capture.Valid()) + { + GTEST_SKIP() << "The platform does not expose a writable temporary-file location."; + } + ASSERT_TRUE(SetDescriptorInheritance(1, inherit)); + + const bool started = Babylon::StandardStreamLogger::Start(); + const int redirectedInheritance = DescriptorInheritance(1); + const bool stopped = Babylon::StandardStreamLogger::Stop(); + const int restoredInheritance = DescriptorInheritance(1); + (void)capture.ReadAndRestore(); + + EXPECT_TRUE(started); + EXPECT_TRUE(stopped); + EXPECT_EQ(redirectedInheritance, inherit); + EXPECT_EQ(restoredInheritance, inherit); + } +} + +TEST(StandardStreamLogger, LargeOutputPreservesOriginalBytes) +{ + if (Babylon::StandardStreamLogger::IsStarted()) + { + GTEST_SKIP() << "The platform host already owns standard-stream forwarding."; + } + + StdoutCapture capture{}; + if (!capture.Valid()) + { + GTEST_SKIP() << "The platform does not expose a writable temporary-file location."; + } + const std::string input = std::string(8192, 'x') + "\r\n" + + std::string{"tail\0more", 9} + "\xE2\x98\x83\n"; + const bool started = Babylon::StandardStreamLogger::Start(); + const size_t written = std::fwrite(input.data(), 1, input.size(), stdout); + const bool stopped = Babylon::StandardStreamLogger::Stop(); + const std::string captured = capture.ReadAndRestore(); + + EXPECT_TRUE(started); + EXPECT_EQ(written, input.size()); + EXPECT_TRUE(stopped); + EXPECT_EQ(captured, input); +} diff --git a/Tests/UnitTests/Shared/StandardStreamLoggerLines.cpp b/Tests/UnitTests/Shared/StandardStreamLoggerLines.cpp new file mode 100644 index 00000000..592df344 --- /dev/null +++ b/Tests/UnitTests/Shared/StandardStreamLoggerLines.cpp @@ -0,0 +1,164 @@ +#include "../../../Core/Foundation/Source/StandardStreamLoggerLines.h" +#include + +#include +#include +#include +#include + +namespace +{ + class LineCapture + { + public: + explicit LineCapture(size_t limit) + : m_limit{limit} + { + } + + void Write(const std::string& data, bool flush = false) + { + m_pending += data; + Babylon::StandardStreamLogger::Detail::EmitPendingLines( + m_pending, m_limit, flush, [this](std::string line) { + EXPECT_LE(line.size(), m_limit); + Lines.push_back(std::move(line)); + }); + } + + std::string Join() const + { + std::string result; + for (const auto& line : Lines) + { + result += line; + } + return result; + } + + std::vector Lines; + + private: + size_t m_limit; + std::string m_pending; + }; + + constexpr std::array Limits{255, 1023, 3800}; +} + +TEST(StandardStreamLoggerLines, CompleteLineAcrossReadsIsBounded) +{ + LineCapture capture{3800}; + const std::string input = std::string(4095, 'x') + '\n'; + for (size_t offset = 0; offset < input.size(); offset += 1024) + { + capture.Write(input.substr(offset, 1024)); + } + ASSERT_EQ(capture.Lines.size(), 2); + EXPECT_EQ(capture.Lines[0].size(), 3800); + EXPECT_EQ(capture.Lines[1].size(), 295); + EXPECT_EQ(capture.Join(), std::string(4095, 'x')); +} + +TEST(StandardStreamLoggerLines, BoundsCompleteAndUnterminatedLinesForEverySink) +{ + for (const auto limit : Limits) + { + for (const size_t size : {limit - 1, limit, limit + 1, 2 * limit, 4 * limit + 3}) + { + for (const bool newline : {false, true}) + { + SCOPED_TRACE(::testing::Message() << limit << ", " << size << ", " << newline); + LineCapture capture{limit}; + const std::string content(size, 'x'); + const std::string input = content + (newline ? "\n" : ""); + for (size_t offset = 0; offset < input.size(); offset += 1024) + { + capture.Write(input.substr(offset, 1024)); + } + capture.Write({}, true); + EXPECT_EQ(capture.Join(), content); + EXPECT_EQ(capture.Lines.size(), (size + limit - 1) / limit); + for (const auto& line : capture.Lines) + { + EXPECT_FALSE(line.empty()); + } + } + } + } +} + +TEST(StandardStreamLoggerLines, ExactLimitDoesNotCreateAnExtraEmptyLine) +{ + for (const auto limit : Limits) + { + for (const auto* ending : {"\n", "\r\n"}) + { + LineCapture capture{limit}; + capture.Write(std::string(limit, 'x')); + for (const char ch : std::string{ending}) + { + capture.Write(std::string(1, ch)); + } + capture.Write({}, true); + ASSERT_EQ(capture.Lines.size(), 1); + EXPECT_EQ(capture.Lines[0], std::string(limit, 'x')); + } + LineCapture capture{limit}; + capture.Write(std::string(limit, 'x') + "\r\n"); + ASSERT_EQ(capture.Lines.size(), 1); + EXPECT_EQ(capture.Lines[0], std::string(limit, 'x')); + } +} + +TEST(StandardStreamLoggerLines, PreservesBlankLinesAndNormalizesLineEndings) +{ + LineCapture capture{255}; + capture.Write("\nfirst\r"); + capture.Write("\n\nsecond\ntail\r", true); + EXPECT_EQ(capture.Lines, (std::vector{"", "first", "", "second", "tail"})); +} + +TEST(StandardStreamLoggerLines, DoesNotDropCarriageReturnAtChunkBoundary) +{ + LineCapture capture{255}; + const std::string input = std::string(254, 'x') + "\rY"; + capture.Write(input + "\n"); + EXPECT_EQ(capture.Join(), input); +} + +TEST(StandardStreamLoggerLines, PreservesUtf8AcrossChunkAndReadBoundaries) +{ + for (const auto limit : Limits) + { + for (const auto* sequence : {"\xC2\xA9", "\xE2\x98\x83", "\xF0\x9F\x98\x80"}) + { + LineCapture capture{limit}; + const std::string content = std::string(limit - 1, 'x') + sequence + "tail"; + for (size_t offset = 0; offset < content.size(); offset += limit) + { + capture.Write(content.substr(offset, limit)); + } + capture.Write("\n"); + ASSERT_EQ(capture.Lines.size(), 2); + EXPECT_EQ(capture.Lines[0], std::string(limit - 1, 'x')); + EXPECT_EQ(capture.Lines[1], std::string{sequence} + "tail"); + EXPECT_EQ(capture.Join(), content); + } + } +} + +TEST(StandardStreamLoggerLines, InvalidUtf8StillMakesProgress) +{ + LineCapture capture{255}; + const std::string content(1024, '\x80'); + capture.Write(content + "\n"); + EXPECT_EQ(capture.Join(), content); +} + +TEST(StandardStreamLoggerLines, EmptyStreamProducesNoLines) +{ + LineCapture capture{255}; + capture.Write({}, true); + EXPECT_TRUE(capture.Lines.empty()); +} diff --git a/Tests/UnitTests/Shared/StandardStreamLoggerPosix.cpp b/Tests/UnitTests/Shared/StandardStreamLoggerPosix.cpp new file mode 100644 index 00000000..211c78a7 --- /dev/null +++ b/Tests/UnitTests/Shared/StandardStreamLoggerPosix.cpp @@ -0,0 +1,100 @@ +#if !defined(_WIN32) + +#include + +#include +#include +#include +#include + +namespace +{ +#include "../../../Core/Foundation/Source/StandardStreamLogger_PosixOps.inl" + + struct Descriptor + { + int Value; + + ~Descriptor() + { + if (Value >= 0) + { + (void)OsClose(Value); + } + } + }; +} + +TEST(StandardStreamLoggerPosix, PrivateDuplicatesAndPipesAreCloseOnExec) +{ + Descriptor original{::open("/dev/null", O_WRONLY)}; + ASSERT_GE(original.Value, 0); + Descriptor copy{OsDuplicate(original.Value)}; + ASSERT_GE(copy.Value, 0); + EXPECT_EQ(::fcntl(copy.Value, F_GETFD), FD_CLOEXEC); + + int pipeFds[2]; + ASSERT_EQ(OsCreatePipe(pipeFds), 0); + Descriptor read{pipeFds[0]}; + Descriptor write{pipeFds[1]}; + EXPECT_EQ(::fcntl(read.Value, F_GETFD), FD_CLOEXEC); + EXPECT_EQ(::fcntl(write.Value, F_GETFD), FD_CLOEXEC); + ASSERT_EQ(OsWrite(write.Value, "x", 1), 1); + char value{}; + EXPECT_EQ(OsRead(read.Value, &value, 1), 1); + EXPECT_EQ(value, 'x'); +} + +TEST(StandardStreamLoggerPosix, PreservesInheritanceOnRedirectAndRestore) +{ + for (const int flags : {0, FD_CLOEXEC}) + { + Descriptor target{::open("/dev/null", O_WRONLY)}; + ASSERT_GE(target.Value, 0); + ASSERT_EQ(::fcntl(target.Value, F_SETFD, flags), 0); + ChannelPlatformState state{}; + ASSERT_TRUE(OsOnStartChannel(state, target.Value, false)); + Descriptor original{OsDuplicate(target.Value)}; + ASSERT_GE(original.Value, 0); + + int pipeFds[2]; + ASSERT_EQ(OsCreatePipe(pipeFds), 0); + Descriptor read{pipeFds[0]}; + Descriptor write{pipeFds[1]}; + ASSERT_EQ(OsDuplicateTo(write.Value, target.Value), 0); + ASSERT_TRUE(OsOnRedirected(state, target.Value)); + EXPECT_EQ(::fcntl(target.Value, F_GETFD), flags); + ASSERT_EQ(OsDuplicateTo(original.Value, target.Value), 0); + ASSERT_TRUE(OsOnRestore(state, target.Value)); + EXPECT_EQ(::fcntl(target.Value, F_GETFD), flags); + } +} + +TEST(StandardStreamLoggerPosix, SupportsAnInitiallyClosedTarget) +{ + Descriptor target{::open("/dev/null", O_WRONLY)}; + ASSERT_GE(target.Value, 0); + const int targetFd = target.Value; + ASSERT_EQ(OsClose(targetFd), 0); + target.Value = -1; + ChannelPlatformState state{}; + ASSERT_TRUE(OsOnStartChannel(state, targetFd, false)); + ASSERT_TRUE(OsOccupyTarget(targetFd)); + target.Value = targetFd; + ASSERT_TRUE(OsOnRedirected(state, target.Value)); + EXPECT_NE(::fcntl(target.Value, F_GETFD) & FD_CLOEXEC, 0); + ASSERT_EQ(OsClose(target.Value), 0); + target.Value = -1; + ASSERT_TRUE(OsOnRestore(state, targetFd)); + EXPECT_EQ(::fcntl(targetFd, F_GETFD), -1); + EXPECT_EQ(errno, EBADF); +} + +TEST(StandardStreamLoggerPosix, InvalidDuplicateReportsBadDescriptor) +{ + errno = 0; + EXPECT_EQ(OsDuplicate(-1), -1); + EXPECT_EQ(errno, EBADF); +} + +#endif