diff --git a/Core/Foundation/CMakeLists.txt b/Core/Foundation/CMakeLists.txt index d089d429..7e4eac35 100644 --- a/Core/Foundation/CMakeLists.txt +++ b/Core/Foundation/CMakeLists.txt @@ -2,13 +2,41 @@ set(SOURCES "Include/Babylon/Api.h" "Include/Babylon/DebugTrace.h" "Include/Babylon/PerfTrace.h" + "Include/Babylon/StandardStreamLogger.h" "Source/DebugTrace.cpp" - "Source/PerfTrace.cpp") + "Source/PerfTrace.cpp" + "Source/StandardStreamLogger.cpp" + "Source/StandardStreamLoggerPlatform.h") + +# .inl bodies are #include'd by the platform TUs (not separate translation units). +# List them in SOURCES only so IDEs/source_group show them next to the .cpp files. +set(STREAM_LOGGER_INCLUDES + "Source/StandardStreamLogger_Shared.inl") + +# Shared tee body (StandardStreamLogger_Shared.inl) + thin OS-ops platform TUs. +# Match AppRuntime: one JSRUNTIMEHOST_PLATFORM implementation is compiled in. +if(JSRUNTIMEHOST_PLATFORM STREQUAL "Win32" OR JSRUNTIMEHOST_PLATFORM STREQUAL "UWP") + list(APPEND SOURCES "Source/StandardStreamLogger_Windows.cpp") +elseif(JSRUNTIMEHOST_PLATFORM STREQUAL "Android") + list(APPEND SOURCES "Source/StandardStreamLogger_Android.cpp") + list(APPEND STREAM_LOGGER_INCLUDES "Source/StandardStreamLogger_PosixOps.inl") +elseif(JSRUNTIMEHOST_PLATFORM STREQUAL "iOS" OR JSRUNTIMEHOST_PLATFORM STREQUAL "macOS") + list(APPEND SOURCES "Source/StandardStreamLogger_Apple.cpp") + list(APPEND STREAM_LOGGER_INCLUDES "Source/StandardStreamLogger_PosixOps.inl") +else() + list(APPEND SOURCES "Source/StandardStreamLogger_Unix.cpp") +endif() + +list(APPEND SOURCES ${STREAM_LOGGER_INCLUDES}) add_library(Foundation ${SOURCES}) +# Ensure generators never try to compile the .inl include bodies on their own. +set_source_files_properties(${STREAM_LOGGER_INCLUDES} PROPERTIES HEADER_FILE_ONLY TRUE) + target_include_directories(Foundation PRIVATE "Include/Babylon" + PRIVATE "Source" INTERFACE "Include") target_link_libraries(Foundation @@ -16,5 +44,9 @@ target_link_libraries(Foundation PRIVATE napi-extensions PRIVATE arcana) +if(ANDROID) + target_link_libraries(Foundation PRIVATE log) +endif() + set_property(TARGET Foundation PROPERTY FOLDER Core) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) \ No newline at end of file diff --git a/Core/Foundation/Include/Babylon/StandardStreamLogger.h b/Core/Foundation/Include/Babylon/StandardStreamLogger.h new file mode 100644 index 00000000..765fb149 --- /dev/null +++ b/Core/Foundation/Include/Babylon/StandardStreamLogger.h @@ -0,0 +1,37 @@ +#pragma once + +#include "Api.h" + +namespace Babylon::StandardStreamLogger +{ + /** + * Starts process-wide standard-stream forwarding. + * + * Android forwards to logcat, Apple platforms forward to os_log, and Windows + * forwards to OutputDebugString while preserving the original stream destination. + * Other Unix platforms already expose standard streams and leave them unchanged. + * + * Returns false if a platform stream could not be redirected. Repeated calls are + * idempotent. + */ + bool BABYLON_API Start(); + + /** + * Flushes pending output, restores the original streams, and stops forwarding. + * + * Returns false if an original stream could not be restored or pending output + * could not be drained before the shutdown timeout. Repeated calls are idempotent. + */ + bool BABYLON_API Stop(); + + /** + * Returns whether Start() has successfully begun process-wide forwarding and + * Stop() has not yet completed. + * + * This is the logical started flag, not a live probe of the underlying file + * descriptors. On platforms that leave stdout/stderr unchanged (plain Linux + * and other non-Android Unix hosts), Start() still succeeds and IsStarted() + * reports true even though no redirection was installed. + */ + bool BABYLON_API IsStarted(); +} \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger.cpp b/Core/Foundation/Source/StandardStreamLogger.cpp new file mode 100644 index 00000000..1b7327ab --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger.cpp @@ -0,0 +1,63 @@ +#include "StandardStreamLogger.h" +#include "StandardStreamLoggerPlatform.h" + +#include +#include + +namespace +{ + std::mutex g_mutex{}; + bool g_started{}; + bool g_exitHandlerRegistered{}; +} + +namespace Babylon::StandardStreamLogger +{ + bool Start() + { + std::lock_guard lock{g_mutex}; + if (g_started) + { + return true; + } + + if (!Platform::Start()) + { + return false; + } + + if (!g_exitHandlerRegistered) + { + if (std::atexit([] { + (void)Babylon::StandardStreamLogger::Stop(); + }) != 0) + { + (void)Platform::Stop(); + return false; + } + g_exitHandlerRegistered = true; + } + + g_started = true; + return true; + } + + bool Stop() + { + std::lock_guard lock{g_mutex}; + if (!g_started) + { + return true; + } + + const bool stopped = Platform::Stop(); + g_started = false; + return stopped; + } + + bool IsStarted() + { + std::lock_guard lock{g_mutex}; + return g_started; + } +} \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLoggerPlatform.h b/Core/Foundation/Source/StandardStreamLoggerPlatform.h new file mode 100644 index 00000000..b0e11c90 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLoggerPlatform.h @@ -0,0 +1,16 @@ +#pragma once + +// Internal platform hooks for StandardStreamLogger. +// Each JSRUNTIMEHOST_PLATFORM TU implements these; the shared API TU owns the +// process-wide mutex / started flag and is the only public entry point. + +namespace Babylon::StandardStreamLogger::Platform +{ + // Install stdout/stderr redirection and drain threads. + // Not synchronized — the shared API holds the process-wide mutex. + bool Start(); + + // Flush, restore original streams, and join (or timeout-detach) drains. + // Not synchronized — the shared API holds the process-wide mutex. + bool Stop(); +} diff --git a/Core/Foundation/Source/StandardStreamLogger_Android.cpp b/Core/Foundation/Source/StandardStreamLogger_Android.cpp new file mode 100644 index 00000000..ef1d0052 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_Android.cpp @@ -0,0 +1,23 @@ +#include "StandardStreamLoggerPlatform.h" + +#include +#include +#include +#include + +#include +#include + +namespace +{ +// POSIX fd helpers (dup/pipe/CLOEXEC/devnull); sink is OsWritePlatform below. +#include "StandardStreamLogger_PosixOps.inl" + + void OsWritePlatform(bool isError, const std::string& line) + { + const int priority = isError ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO; + __android_log_write(priority, "JsRuntimeHost", line.c_str()); + } +} + +#include "StandardStreamLogger_Shared.inl" \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger_Apple.cpp b/Core/Foundation/Source/StandardStreamLogger_Apple.cpp new file mode 100644 index 00000000..5c514d88 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_Apple.cpp @@ -0,0 +1,23 @@ +#include "StandardStreamLoggerPlatform.h" + +#include +#include +#include +#include + +#include +#include + +namespace +{ +// POSIX fd helpers (dup/pipe/CLOEXEC/devnull); sink is OsWritePlatform below. +#include "StandardStreamLogger_PosixOps.inl" + + void OsWritePlatform(bool isError, const std::string& line) + { + const os_log_type_t type = isError ? OS_LOG_TYPE_ERROR : OS_LOG_TYPE_DEFAULT; + os_log_with_type(OS_LOG_DEFAULT, type, "%{public}s", line.c_str()); + } +} + +#include "StandardStreamLogger_Shared.inl" \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl b/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl new file mode 100644 index 00000000..ef215102 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_PosixOps.inl @@ -0,0 +1,83 @@ +// POSIX fd primitives shared by Android and Apple. Included inside an anonymous +// namespace that already provides OsWritePlatform. + +struct ChannelPlatformState +{ +}; + +int OsDuplicate(int fd) +{ + return ::dup(fd); +} + +int OsDuplicateTo(int source, int target) +{ + return ::dup2(source, target) < 0 ? -1 : 0; +} + +int OsClose(int fd) +{ + return ::close(fd); +} + +int64_t OsRead(int fd, void* data, size_t size) +{ + return ::read(fd, data, size); +} + +int64_t OsWrite(int fd, const void* data, size_t size) +{ + return ::write(fd, data, size); +} + +int OsCreatePipe(int fds[2]) +{ + if (::pipe(fds) != 0) + { + return -1; + } + // Mark both ends CLOEXEC. Leaving the write end inheritable would let a + // concurrent exec keep the pipe open and delay Drain()'s EOF on Stop(). + if (::fcntl(fds[0], F_SETFD, FD_CLOEXEC) != 0 || + ::fcntl(fds[1], F_SETFD, FD_CLOEXEC) != 0) + { + const int error = errno; + (void)::close(fds[0]); + (void)::close(fds[1]); + errno = error; + return -1; + } + return 0; +} + +bool OsOccupyTarget(int target) +{ + const int nullFd = ::open("/dev/null", O_WRONLY); + if (nullFd < 0) + { + return false; + } + if (nullFd == target) + { + return true; + } + + const bool duplicated = OsDuplicateTo(nullFd, target) == 0; + (void)OsClose(nullFd); + return duplicated; +} + +bool OsOnStartChannel(ChannelPlatformState&, int, bool) +{ + return true; +} + +bool OsOnRedirected(ChannelPlatformState&, int) +{ + return true; +} + +bool OsOnRestore(ChannelPlatformState&, int) +{ + return true; +} \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger_Shared.inl b/Core/Foundation/Source/StandardStreamLogger_Shared.inl new file mode 100644 index 00000000..db765b1c --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_Shared.inl @@ -0,0 +1,367 @@ +// Shared stdout/stderr tee + drain implementation. +// +// Platform TUs define these in the enclosing anonymous namespace, then include: +// struct ChannelPlatformState { ... }; +// int OsDuplicate(int fd); +// int OsDuplicateTo(int source, int target); +// int OsClose(int fd); +// int64_t OsRead(int fd, void* data, size_t size); +// int64_t OsWrite(int fd, const void* data, size_t size); +// int OsCreatePipe(int fds[2]); +// bool OsOccupyTarget(int target); +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + enum class Stream + { + Output, + Error, + }; + + struct Channel + { + int Target{-1}; + int Original{-1}; + ChannelPlatformState Platform{}; + std::future Completion{}; + std::thread Reader{}; + }; + + // Drain futures retained after Stop() times out and detaches the reader. + // Start() reaps these before installing a new redirection. + std::vector> g_outstandingDrains{}; + Channel g_stdout{}; + Channel g_stderr{}; + + bool WriteAll(int fd, const char* data, size_t size) + { + while (size != 0) + { + const auto written = OsWrite(fd, data, size); + if (written > 0) + { + data += written; + size -= static_cast(written); + continue; + } + if (written < 0 && errno == EINTR) + { + continue; + } + return false; + } + 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}; + std::array buffer{}; + std::string pending{}; + + for (;;) + { + const auto count = OsRead(readFd, buffer.data(), buffer.size()); + if (count == 0) + { + break; + } + if (count < 0) + { + if (errno == EINTR) + { + continue; + } + break; + } + + const size_t size = static_cast(count); + if (originalFd >= 0) + { + (void)WriteAll(originalFd, buffer.data(), size); + } + + 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); + } + } + + if (!pending.empty()) + { + EmitLine(stream, std::move(pending)); + } + (void)OsClose(readFd); + if (originalFd >= 0) + { + (void)OsClose(originalFd); + } + } + + void RollbackRedirect(Channel& channel, int pipeReadFd, int readerOriginal) + { + if (channel.Original >= 0) + { + (void)OsDuplicateTo(channel.Original, channel.Target); + (void)OsClose(channel.Original); + } + else + { + (void)OsClose(channel.Target); + } + (void)OsOnRestore(channel.Platform, channel.Target); + if (pipeReadFd >= 0) + { + (void)OsClose(pipeReadFd); + } + if (readerOriginal >= 0) + { + (void)OsClose(readerOriginal); + } + channel = {}; + } + + bool StartChannel(Channel& channel, int target, Stream stream) + { + channel.Target = target; + if (!OsOnStartChannel(channel.Platform, target, stream == Stream::Error)) + { + channel = {}; + return false; + } + + errno = 0; + channel.Original = OsDuplicate(target); + if (channel.Original < 0 && errno != EBADF) + { + channel = {}; + return false; + } + if (channel.Original < 0 && !OsOccupyTarget(target)) + { + channel = {}; + return false; + } + + int pipeFds[2]{-1, -1}; + if (OsCreatePipe(pipeFds) != 0) + { + if (channel.Original >= 0) + { + (void)OsClose(channel.Original); + } + else + { + (void)OsClose(target); + } + channel = {}; + return false; + } + + if (OsDuplicateTo(pipeFds[1], target) != 0) + { + (void)OsClose(pipeFds[0]); + (void)OsClose(pipeFds[1]); + if (channel.Original >= 0) + { + (void)OsClose(channel.Original); + } + else + { + (void)OsClose(target); + } + channel = {}; + return false; + } + (void)OsClose(pipeFds[1]); + + if (!OsOnRedirected(channel.Platform, target)) + { + RollbackRedirect(channel, pipeFds[0], -1); + return false; + } + + int readerOriginal{-1}; + if (channel.Original >= 0) + { + readerOriginal = OsDuplicate(channel.Original); + if (readerOriginal < 0) + { + RollbackRedirect(channel, pipeFds[0], -1); + return false; + } + } + + std::promise completed{}; + channel.Completion = completed.get_future(); + try + { + channel.Reader = std::thread{ + [readFd = pipeFds[0], originalFd = readerOriginal, stream, completed = std::move(completed)]() mutable { + Drain(readFd, originalFd, stream); + completed.set_value(); + }}; + } + catch (const std::system_error&) + { + RollbackRedirect(channel, pipeFds[0], readerOriginal); + return false; + } + return true; + } + + bool StopChannel(Channel& channel) + { + bool restored{true}; + if (channel.Original >= 0) + { + restored = OsDuplicateTo(channel.Original, channel.Target) == 0; + if (!restored) + { + (void)OsClose(channel.Target); + } + } + else + { + restored = OsClose(channel.Target) == 0; + } + + restored = OsOnRestore(channel.Platform, channel.Target) && restored; + + if (channel.Original >= 0) + { + (void)OsClose(channel.Original); + } + + if (channel.Reader.joinable()) + { + if (channel.Completion.wait_for(std::chrono::seconds{2}) == std::future_status::ready) + { + channel.Reader.join(); + } + else + { + // Detach so Stop can return, but keep the future so Start() + // can refuse a restart until this drain actually finishes. + g_outstandingDrains.push_back(std::move(channel.Completion)); + channel.Reader.detach(); + restored = false; + } + } + channel = {}; + return restored; + } + + bool ReapOutstandingDrains(std::chrono::milliseconds timeout) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (!g_outstandingDrains.empty()) + { + auto& front = g_outstandingDrains.front(); + const auto remaining = deadline - std::chrono::steady_clock::now(); + if (remaining <= std::chrono::milliseconds::zero()) + { + if (front.wait_for(std::chrono::milliseconds::zero()) != std::future_status::ready) + { + return false; + } + } + else if (front.wait_for(remaining) != std::future_status::ready) + { + return false; + } + + g_outstandingDrains.erase(g_outstandingDrains.begin()); + } + return true; + } +} + +namespace Babylon::StandardStreamLogger::Platform +{ + bool Start() + { + if (!ReapOutstandingDrains(std::chrono::seconds{2})) + { + return false; + } + + std::cout.flush(); + std::cerr.flush(); + std::fflush(stdout); + std::fflush(stderr); + + if (!StartChannel(g_stdout, 1, Stream::Output)) + { + return false; + } + if (!StartChannel(g_stderr, 2, Stream::Error)) + { + (void)StopChannel(g_stdout); + return false; + } + return true; + } + + bool Stop() + { + std::cout.flush(); + std::cerr.flush(); + std::fflush(stdout); + std::fflush(stderr); + bool stopped = StopChannel(g_stdout); + stopped = StopChannel(g_stderr) && stopped; + return stopped; + } +} \ No newline at end of file diff --git a/Core/Foundation/Source/StandardStreamLogger_Unix.cpp b/Core/Foundation/Source/StandardStreamLogger_Unix.cpp new file mode 100644 index 00000000..32ff735e --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_Unix.cpp @@ -0,0 +1,18 @@ +#include "StandardStreamLoggerPlatform.h" + +// Plain Unix already exposes stdout/stderr to the process environment (terminals, +// journald, etc.), so there is nothing to redirect. Start/Stop are successful +// no-ops; the shared API still flips IsStarted() for a uniform host contract. + +namespace Babylon::StandardStreamLogger::Platform +{ + bool Start() + { + return true; + } + + bool Stop() + { + return true; + } +} diff --git a/Core/Foundation/Source/StandardStreamLogger_Windows.cpp b/Core/Foundation/Source/StandardStreamLogger_Windows.cpp new file mode 100644 index 00000000..e2ff1559 --- /dev/null +++ b/Core/Foundation/Source/StandardStreamLogger_Windows.cpp @@ -0,0 +1,165 @@ +#include "StandardStreamLoggerPlatform.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace +{ + void IgnoreInvalidParameter( + const wchar_t*, + const wchar_t*, + const wchar_t*, + unsigned int, + uintptr_t) + { + } + + // Win32/UWP need to keep GetStdHandle/SetStdHandle in sync with CRT fds. + struct ChannelPlatformState + { + DWORD StandardHandle{}; + HANDLE OriginalHandle{INVALID_HANDLE_VALUE}; + bool OriginalHandleUsesTarget{}; + }; + + int OsDuplicate(int fd) + { + const auto previousHandler = ::_set_thread_local_invalid_parameter_handler(IgnoreInvalidParameter); + const int duplicated = ::_dup(fd); + (void)::_set_thread_local_invalid_parameter_handler(previousHandler); + return duplicated; + } + + int OsDuplicateTo(int source, int target) + { + return ::_dup2(source, target); + } + + int OsClose(int fd) + { + return ::_close(fd); + } + + int64_t OsRead(int fd, void* data, size_t size) + { + return ::_read(fd, data, static_cast(size)); + } + + int64_t OsWrite(int fd, const void* data, size_t size) + { + return ::_write(fd, data, static_cast(size)); + } + + int OsCreatePipe(int fds[2]) + { + // UWP's CRT does not expose _pipe. CreatePipe + _open_osfhandle works on + // desktop Win32 and UWP, and keeps the ends non-inheritable. + SECURITY_ATTRIBUTES attributes{}; + attributes.nLength = sizeof(attributes); + attributes.bInheritHandle = FALSE; + + HANDLE readHandle{INVALID_HANDLE_VALUE}; + HANDLE writeHandle{INVALID_HANDLE_VALUE}; + if (!::CreatePipe(&readHandle, &writeHandle, &attributes, 4096)) + { + return -1; + } + + fds[0] = ::_open_osfhandle(reinterpret_cast(readHandle), _O_BINARY); + if (fds[0] < 0) + { + (void)::CloseHandle(readHandle); + (void)::CloseHandle(writeHandle); + return -1; + } + + fds[1] = ::_open_osfhandle(reinterpret_cast(writeHandle), _O_BINARY); + if (fds[1] < 0) + { + (void)::_close(fds[0]); + (void)::CloseHandle(writeHandle); + return -1; + } + + return 0; + } + + bool OsOccupyTarget(int target) + { + // 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) + { + return false; + } + if (nullFd == target) + { + return true; + } + + const bool duplicated = OsDuplicateTo(nullFd, target) == 0; + (void)OsClose(nullFd); + return duplicated; + } + + void OsWritePlatform(bool /*isError*/, const std::string& line) + { + std::string output{line}; + output.push_back('\n'); + ::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); + state.OriginalHandleUsesTarget = + targetHandle != -1 && + state.OriginalHandle != nullptr && + state.OriginalHandle != INVALID_HANDLE_VALUE && + state.OriginalHandle == reinterpret_cast(targetHandle); + return true; + } + + bool OsOnRedirected(ChannelPlatformState& state, int target) + { + const intptr_t pipeHandle = GetOsHandle(target); + if (pipeHandle == -1) + { + return false; + } + return ::SetStdHandle(state.StandardHandle, reinterpret_cast(pipeHandle)) != FALSE; + } + + bool OsOnRestore(ChannelPlatformState& state, int target) + { + HANDLE handle = state.OriginalHandle; + if (state.OriginalHandleUsesTarget) + { + const intptr_t restoredHandle = GetOsHandle(target); + if (restoredHandle == -1) + { + return false; + } + handle = reinterpret_cast(restoredHandle); + } + return ::SetStdHandle(state.StandardHandle, handle) != FALSE; + } +} + +#include "StandardStreamLogger_Shared.inl" \ No newline at end of file diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 0af5caa8..5f7c83a0 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -18,6 +18,7 @@ npm(install --silent WORKING_DIRECTORY ${TESTS_DIR}) add_library(UnitTestsJNI SHARED JNI.cpp + ${UNIT_TESTS_DIR}/Shared/StandardStreamLogger.cpp ${UNIT_TESTS_DIR}/Shared/Shared.h ${UNIT_TESTS_DIR}/Shared/Shared.cpp) diff --git a/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp b/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp index 4415ce87..85612af3 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp +++ b/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp @@ -2,8 +2,8 @@ #include #include #include -#include #include "Babylon/DebugTrace.h" +#include "Babylon/StandardStreamLogger.h" #include extern "C" JNIEXPORT jint JNICALL @@ -14,11 +14,15 @@ Java_com_jsruntimehost_unittests_Native_javaScriptTests(JNIEnv* env, jclass claz throw std::runtime_error{"Failed to get Java VM"}; } + if (!Babylon::StandardStreamLogger::Start()) + { + __android_log_write(ANDROID_LOG_ERROR, "JsRuntimeHost", "Failed to start standard-stream forwarding."); + return -1; + } + jclass webSocketClass{env->FindClass("com/jsruntimehost/unittests/WebSocket")}; java::websocket::WebSocketClient::InitializeJavaWebSocketClass(webSocketClass, env); - android::StdoutLogger::Start(); - android::global::Initialize(javaVM, context); Babylon::DebugTrace::EnableDebugTrace(true); @@ -26,8 +30,13 @@ Java_com_jsruntimehost_unittests_Native_javaScriptTests(JNIEnv* env, jclass claz auto testResult = RunTests(); - android::StdoutLogger::Stop(); + const bool loggerStopped = Babylon::StandardStreamLogger::Stop(); + if (!loggerStopped) + { + __android_log_write(ANDROID_LOG_ERROR, "JsRuntimeHost", + "Failed to stop standard-stream forwarding (restore or drain timeout)."); + } - java::websocket::WebSocketClient::DestructJavaWebSocketClass(env); - return testResult; -} + java::websocket::WebSocketClient::DestructJavaWebSocketClass(env); + return loggerStopped ? testResult : -1; + } diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 2dbc7619..b8446eb2 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -8,6 +8,7 @@ set(TYPE_SCRIPTS file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES + "Shared/StandardStreamLogger.cpp" "Shared/Shared.cpp" "Shared/Shared.h") diff --git a/Tests/UnitTests/Shared/StandardStreamLogger.cpp b/Tests/UnitTests/Shared/StandardStreamLogger.cpp new file mode 100644 index 00000000..7f0c71e6 --- /dev/null +++ b/Tests/UnitTests/Shared/StandardStreamLogger.cpp @@ -0,0 +1,198 @@ +#include +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +namespace +{ +#if defined(_WIN32) + int DuplicateFileDescriptor(int fd) + { + return ::_dup(fd); + } + + int DuplicateFileDescriptorTo(int source, int target) + { + return ::_dup2(source, target); + } + + int CloseFileDescriptor(int fd) + { + return ::_close(fd); + } + + int FileDescriptor(FILE* file) + { + return ::_fileno(file); + } +#else + int DuplicateFileDescriptor(int fd) + { + return ::dup(fd); + } + + int DuplicateFileDescriptorTo(int source, int target) + { + return ::dup2(source, target) < 0 ? -1 : 0; + } + + int CloseFileDescriptor(int fd) + { + return ::close(fd); + } + + int FileDescriptor(FILE* file) + { + return ::fileno(file); + } +#endif + + class StdoutCapture + { + public: + StdoutCapture() + { + std::fflush(stdout); + m_original = DuplicateFileDescriptor(1); +#if defined(_WIN32) + m_originalStdHandle = ::GetStdHandle(STD_OUTPUT_HANDLE); + const intptr_t originalFdHandle = ::_get_osfhandle(1); + m_originalStdHandleUsesTarget = + originalFdHandle != -1 && + m_originalStdHandle != nullptr && + m_originalStdHandle != INVALID_HANDLE_VALUE && + m_originalStdHandle == reinterpret_cast(originalFdHandle); +#endif + m_file = std::tmpfile(); + if (m_original < 0 || m_file == nullptr || DuplicateFileDescriptorTo(FileDescriptor(m_file), 1) != 0) + { + Restore(); + return; + } +#if defined(_WIN32) + const intptr_t handle = ::_get_osfhandle(1); + if (handle == -1 || !::SetStdHandle(STD_OUTPUT_HANDLE, reinterpret_cast(handle))) + { + Restore(); + return; + } +#endif + m_valid = true; + } + + ~StdoutCapture() + { + Restore(); + } + + bool Valid() const + { + return m_valid; + } + + std::string ReadAndRestore() + { + std::fflush(stdout); + std::rewind(m_file); + + std::string result{}; + std::array buffer{}; + for (;;) + { + const size_t size = std::fread(buffer.data(), 1, buffer.size(), m_file); + result.append(buffer.data(), size); + if (size != buffer.size()) + { + break; + } + } + + Restore(); + return result; + } + + private: + void Restore() + { + if (m_original >= 0) + { + std::fflush(stdout); + const bool restored = DuplicateFileDescriptorTo(m_original, 1) == 0; + (void)CloseFileDescriptor(m_original); + m_original = -1; +#if defined(_WIN32) + if (restored) + { + HANDLE handle = m_originalStdHandle; + if (m_originalStdHandleUsesTarget) + { + const intptr_t restoredFdHandle = ::_get_osfhandle(1); + handle = restoredFdHandle == -1 + ? INVALID_HANDLE_VALUE + : reinterpret_cast(restoredFdHandle); + } + (void)::SetStdHandle(STD_OUTPUT_HANDLE, handle); + } +#endif + } + if (m_file != nullptr) + { + std::fclose(m_file); + m_file = nullptr; + } + m_valid = false; + } + + FILE* m_file{}; + int m_original{-1}; + bool m_valid{}; +#if defined(_WIN32) + HANDLE m_originalStdHandle{INVALID_HANDLE_VALUE}; + bool m_originalStdHandleUsesTarget{}; +#endif + }; +} + +TEST(StandardStreamLogger, Lifecycle) +{ + 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 bool started = Babylon::StandardStreamLogger::Start(); + const bool isStarted = Babylon::StandardStreamLogger::IsStarted(); + const bool secondStart = Babylon::StandardStreamLogger::Start(); + + // Only exercise stdout here. iOS CI captures simctl launch stderr as the + // process exit code (`2> /tmp/exitCode`), so writing to stderr would corrupt + // that handshake even when the tests themselves succeed. + std::fputs("StandardStreamLogger stdout test", stdout); + std::fflush(stdout); + + const bool stopped = Babylon::StandardStreamLogger::Stop(); + const std::string captured = capture.ReadAndRestore(); + + EXPECT_TRUE(started); + EXPECT_TRUE(isStarted); + EXPECT_TRUE(secondStart); + EXPECT_TRUE(stopped); + EXPECT_FALSE(Babylon::StandardStreamLogger::IsStarted()); + EXPECT_TRUE(Babylon::StandardStreamLogger::Stop()); + EXPECT_EQ(captured, "StandardStreamLogger stdout test"); +}