From a5abd6b6597641f6fe86e9ef07afb0942f55583a Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 18:39:03 +0100 Subject: [PATCH 1/2] feat(runtime): forward stdout and stderr to os_log for the CLI Without a console an app's stdio is /dev/null, so print() and, on devices, NSLog never reach any log stream, and a CLI can't attach to them after launch. In debug builds whose CLI sets forwardStdio in the app's package.json, with stdout still /dev/null, the runtime now pipes them into os_log under the org.nativescript.stdio subsystem. Long lines are split like console messages. --- NativeScript/NativeScript.mm | 2 + NativeScript/runtime/Helpers.h | 6 +- NativeScript/runtime/Helpers.mm | 2 +- NativeScript/runtime/StdioForwarder.h | 14 ++++ NativeScript/runtime/StdioForwarder.mm | 98 ++++++++++++++++++++++++++ v8ios.xcodeproj/project.pbxproj | 8 +++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 NativeScript/runtime/StdioForwarder.h create mode 100644 NativeScript/runtime/StdioForwarder.mm diff --git a/NativeScript/NativeScript.mm b/NativeScript/NativeScript.mm index 54f4b14a..450c96de 100644 --- a/NativeScript/NativeScript.mm +++ b/NativeScript/NativeScript.mm @@ -11,6 +11,7 @@ #include "runtime/NativeScriptException.h" #include "runtime/Runtime.h" #include "runtime/RuntimeConfig.h" +#include "runtime/StdioForwarder.h" #include "runtime/Tasks.h" using namespace v8; @@ -242,6 +243,7 @@ - (instancetype)initializeWithConfig:(Config*)config { RuntimeConfig.LogToSystemConsole = [config LogToSystemConsole]; Runtime::Initialize(); + StartStdioForwarding(); DestroyMainRuntime(); runtime_ = new Runtime(); diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 0eddc294..90e37d3e 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -358,10 +358,12 @@ void LogBacktrace(int skip = 1); // Whether split messages should carry markers for the CLI to stitch them with. bool ShouldMarkLogChunks(); +bool IsDevNull(int fd); + #if TNS_HAVE_OS_LOG // The prefix is repeated on every chunk, so that each stays a complete log line. static inline void TNS_WriteToOsLog(const char* message, os_log_type_t type = OS_LOG_TYPE_DEFAULT, - std::string_view prefix = "") { + std::string_view prefix = "", os_log_t log = OS_LOG_DEFAULT) { ForEachLogChunk(message, kMaxLogChunkBytes - prefix.size(), [&](std::string_view chunk, LogChunkBreak chunkBreak) { const char* marker = ""; @@ -369,7 +371,7 @@ static inline void TNS_WriteToOsLog(const char* message, os_log_type_t type = OS marker = chunkBreak == LogChunkBreak::kLine ? kLogChunkLineMarker : kLogChunkJoinMarker; } - os_log_with_type(OS_LOG_DEFAULT, type, "%{public}.*s%{public}.*s%{public}s", + os_log_with_type(log, type, "%{public}.*s%{public}.*s%{public}s", static_cast(prefix.size()), prefix.data(), static_cast(chunk.size()), chunk.data(), marker); }); diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index e9bf22ad..5ae2978c 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -493,7 +493,7 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { } } -static bool IsDevNull(int fd) { +bool tns::IsDevNull(int fd) { struct stat target; struct stat devNull; return fstat(fd, &target) == 0 && stat("/dev/null", &devNull) == 0 && diff --git a/NativeScript/runtime/StdioForwarder.h b/NativeScript/runtime/StdioForwarder.h new file mode 100644 index 00000000..dc32d847 --- /dev/null +++ b/NativeScript/runtime/StdioForwarder.h @@ -0,0 +1,14 @@ +#ifndef StdioForwarder_h +#define StdioForwarder_h + +namespace tns { + +// Sends what the app writes to stdout and stderr (print(), NSLog on devices) to +// os_log. Without a console those go to /dev/null, and a process can't be +// attached to later: its stdio is fixed at launch. Only debug builds whose CLI +// asks for it through the forwardStdio key of the app's package.json do this. +void StartStdioForwarding(); + +} // namespace tns + +#endif /* StdioForwarder_h */ diff --git a/NativeScript/runtime/StdioForwarder.mm b/NativeScript/runtime/StdioForwarder.mm new file mode 100644 index 00000000..a048ca9d --- /dev/null +++ b/NativeScript/runtime/StdioForwarder.mm @@ -0,0 +1,98 @@ +#include "StdioForwarder.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Helpers.h" +#include "Runtime.h" +#include "RuntimeConfig.h" + +namespace tns { + +namespace { + +// The CLI tells these entries apart from JS console output by this subsystem. +constexpr const char* kStdioSubsystem = "org.nativescript.stdio"; + +// A longer line goes out in parts instead of growing the buffer without limit. +constexpr size_t kMaxBufferedLineBytes = 64 * 1024; + +void Forward(int fd, const char* category, dispatch_queue_t queue) { + int fds[2]; + if (pipe(fds) != 0) { + return; + } + if (dup2(fds[1], fd) < 0) { + close(fds[0]); + close(fds[1]); + return; + } + close(fds[1]); + + int readEnd = fds[0]; + os_log_t log = os_log_create(kStdioSubsystem, category); + auto pending = std::make_shared(); + dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, readEnd, 0, queue); + dispatch_source_set_event_handler(source, ^{ + char buffer[4096]; + ssize_t count = read(readEnd, buffer, sizeof(buffer)); + if (count <= 0) { + return; + } + pending->append(buffer, count); + + size_t start = 0; + size_t end; + while ((end = pending->find('\n', start)) != std::string::npos) { + TNS_WriteToOsLog(pending->substr(start, end - start).c_str(), OS_LOG_TYPE_DEFAULT, "", log); + start = end + 1; + } + pending->erase(0, start); + + if (pending->size() >= kMaxBufferedLineBytes) { + TNS_WriteToOsLog(pending->c_str(), OS_LOG_TYPE_DEFAULT, "", log); + pending->clear(); + } + }); + dispatch_resume(source); +} + +} // namespace + +void StartStdioForwarding() { + static std::once_flag once; + std::call_once(once, [] { + if (!RuntimeConfig.IsDebug || RuntimeConfig.ApplicationPath.empty()) { + return; + } + + id value = Runtime::GetAppConfigValue("forwardStdio"); + if (value == nil || ![value boolValue] || !IsDevNull(STDOUT_FILENO)) { + return; + } + + // Decided once and from stdout, which is about to stop being /dev/null. + ShouldMarkLogChunks(); + + // Otherwise stdout is fully buffered into a pipe, and print() only shows up + // when the buffer fills. + setvbuf(stdout, nullptr, _IOLBF, 0); + + // Serial, so that the chunks of a split line stay together. + dispatch_queue_t queue = + dispatch_queue_create("org.nativescript.stdio", DISPATCH_QUEUE_SERIAL); + Forward(STDOUT_FILENO, "stdout", queue); +#if !TARGET_OS_SIMULATOR + // On the simulator NSLog reaches the unified log as well, so forwarding + // stderr there would print it twice. + Forward(STDERR_FILENO, "stderr", queue); +#endif + }); +} + +} // namespace tns diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 7034da3d..211e4a58 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -340,6 +340,8 @@ A1B2C3D40000000000000004 /* WorkerInspectorClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* WorkerInspectorClient.mm */; }; F6191AB129C0FCE8003F588F /* InspectorServer.h in Headers */ = {isa = PBXBuildFile; fileRef = F6191AA929C0FCE7003F588F /* InspectorServer.h */; }; F6191AB229C0FCE8003F588F /* InspectorServer.mm in Sources */ = {isa = PBXBuildFile; fileRef = F6191AAA29C0FCE7003F588F /* InspectorServer.mm */; settings = {COMPILER_FLAGS = "-fobjc-arc"; }; }; + A1C0DE5400000000000000A4 /* StdioForwarder.h in Headers */ = {isa = PBXBuildFile; fileRef = A1C0DE5200000000000000A2 /* StdioForwarder.h */; }; + A1C0DE5500000000000000A5 /* StdioForwarder.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE5300000000000000A3 /* StdioForwarder.mm */; }; F6191AB629C0FF87003F588F /* utils.h in Headers */ = {isa = PBXBuildFile; fileRef = F6191AB429C0FF86003F588F /* utils.h */; }; F6191AB729C0FF87003F588F /* utils.mm in Sources */ = {isa = PBXBuildFile; fileRef = F6191AB529C0FF86003F588F /* utils.mm */; }; D3FA1E7A0000000000000004 /* NativeScriptDefaultMetadata.c in Sources */ = {isa = PBXBuildFile; fileRef = D3FA1E7A0000000000000002 /* NativeScriptDefaultMetadata.c */; }; @@ -819,6 +821,8 @@ C2DDEB6B229EAC8200345BFE /* Console.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Console.cpp; sourceTree = ""; }; C2DDEB6D229EAC8200345BFE /* Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Helpers.h; sourceTree = ""; }; A1C0DE5100000000000000A1 /* LogChunks.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LogChunks.h; sourceTree = ""; }; + A1C0DE5200000000000000A2 /* StdioForwarder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StdioForwarder.h; sourceTree = ""; }; + A1C0DE5300000000000000A3 /* StdioForwarder.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = StdioForwarder.mm; sourceTree = ""; }; C2DDEB6E229EAC8200345BFE /* Metadata.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Metadata.h; sourceTree = ""; }; C2DDEB6F229EAC8200345BFE /* ArrayAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ArrayAdapter.h; sourceTree = ""; }; C2DDEB70229EAC8200345BFE /* Metadata.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = Metadata.mm; sourceTree = ""; }; @@ -1499,6 +1503,8 @@ C266569C22B282BA00EE15CC /* FunctionReference.cpp */, C2DDEB6D229EAC8200345BFE /* Helpers.h */, A1C0DE5100000000000000A1 /* LogChunks.h */, + A1C0DE5200000000000000A2 /* StdioForwarder.h */, + A1C0DE5300000000000000A3 /* StdioForwarder.mm */, C2DDEB7D229EAC8200345BFE /* Helpers.mm */, AA5DBFD12EC191F8008D12F9 /* HttpLoader.h */, AA5DBFD22EC191F8008D12F9 /* HttpLoader.mm */, @@ -1669,6 +1675,7 @@ C2DDEB97229EAC8300345BFE /* Metadata.h in Headers */, C247C16522F82842001D2CA2 /* v8-platform.h in Headers */, F6191AB129C0FCE8003F588F /* InspectorServer.h in Headers */, + A1C0DE5400000000000000A4 /* StdioForwarder.h in Headers */, C247C16722F82842001D2CA2 /* libplatform.h in Headers */, C2DDEBA4229EAC8300345BFE /* Runtime.h in Headers */, F1CB51842D5C37100042555E /* URLPatternImpl.h in Headers */, @@ -2352,6 +2359,7 @@ C266569722AFFFB000EE15CC /* Reference.cpp in Sources */, 6573B9EE291FE5B700B0ED7C /* JSIRuntime.m in Sources */, F6191AB229C0FCE8003F588F /* InspectorServer.mm in Sources */, + A1C0DE5500000000000000A5 /* StdioForwarder.mm in Sources */, 6573B9D3291FE29F00B0ED7C /* HostProxy.cpp in Sources */, 6573B9E5291FE2A700B0ED7C /* jsi.cpp in Sources */, C2DDEBB3229EAC8300345BFE /* ClassBuilder.mm in Sources */, From 87152649fd76e27dac8c5d82e7d232c15c9ad82f Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:27 +0100 Subject: [PATCH 2/2] fix(runtime): ignore a forwardStdio value that isn't a boolean --- NativeScript/runtime/StdioForwarder.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NativeScript/runtime/StdioForwarder.mm b/NativeScript/runtime/StdioForwarder.mm index a048ca9d..4af1f95d 100644 --- a/NativeScript/runtime/StdioForwarder.mm +++ b/NativeScript/runtime/StdioForwarder.mm @@ -72,7 +72,8 @@ void StartStdioForwarding() { } id value = Runtime::GetAppConfigValue("forwardStdio"); - if (value == nil || ![value boolValue] || !IsDevNull(STDOUT_FILENO)) { + if (![value isKindOfClass:[NSNumber class]] || ![value boolValue] || + !IsDevNull(STDOUT_FILENO)) { return; }