From b9d8c7c058058cd979339b8edd480b5448cc3be1 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 1/3] feat(console): split long messages for os_log and report errors as errors os_log truncates messages at about 1 KB. They now go out as chunks of at most 900 bytes, cut at line breaks where possible and never inside a UTF-8 sequence. console.error uses OS_LOG_TYPE_ERROR. When the CLI writes logChunkMarkers to the app's package.json and stdout is /dev/null, each chunk ends with an invisible marker that lets the CLI rebuild the original message. Other readers see plain split lines: older CLIs never set the key, and an attached console fails the stdout check. --- NativeScript/runtime/Console.cpp | 20 +++++++++--- NativeScript/runtime/Helpers.h | 29 +++++++++++++++-- NativeScript/runtime/Helpers.mm | 21 ++++++++++++ NativeScript/runtime/LogChunks.h | 55 ++++++++++++++++++++++++++++++++ v8ios.xcodeproj/project.pbxproj | 2 ++ 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 NativeScript/runtime/LogChunks.h diff --git a/NativeScript/runtime/Console.cpp b/NativeScript/runtime/Console.cpp index ef8a3b58..3047b1e6 100644 --- a/NativeScript/runtime/Console.cpp +++ b/NativeScript/runtime/Console.cpp @@ -25,6 +25,19 @@ namespace { struct ConsoleTimersState { robin_hood::unordered_map startedAt; }; + +// Errors get the error log type so log readers can tell severity apart. The +// default handle is kept on purpose: a subsystem would show up in the text of +// syslog-style output, which older CLIs parse. +void LogConsoleMessage(bool isError, const std::string& prefix, + const std::string& message) { +#if TNS_HAVE_OS_LOG + TNS_WriteToOsLog(message.c_str(), + isError ? OS_LOG_TYPE_ERROR : OS_LOG_TYPE_DEFAULT, prefix); +#else + Log("%s%s", prefix.c_str(), message.c_str()); +#endif +} } // namespace void Console::Init(Local context) { @@ -117,9 +130,8 @@ void Console::LogCallback(const FunctionCallbackInfo& args) { ConsoleAPIType method = VerbosityToInspectorMethod(verbosityLevel); SendToDevToolsFrontEnd(method, args); - std::string msgWithVerbosity = - "CONSOLE " + verbosityLevelUpper + ": " + msgToLog; - Log("%s", msgWithVerbosity.c_str()); + LogConsoleMessage(verbosityLevel == "error", + "CONSOLE " + verbosityLevelUpper + ": ", msgToLog); if (RuntimeConfig.IsDebug && Runtime::showErrorDisplay() && verbosityLevel == "error" && hasStackTrace) { @@ -158,7 +170,7 @@ void Console::AssertCallback(const FunctionCallbackInfo& args) { std::string log = ss.str(); SendToDevToolsFrontEnd(ConsoleAPIType::kAssert, args); - Log("%s", log.c_str()); + LogConsoleMessage(true, "", log); } } diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index d4fd42b8..0eddc294 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -31,6 +31,8 @@ extern "C" void NSLog(CFStringRef format, ...); #define TNS_HAVE_OS_LOG 0 #endif +#include "LogChunks.h" + namespace tns { inline v8::Local ToV8String(v8::Isolate* isolate, const std::string& value) { @@ -353,6 +355,27 @@ void LogBacktrace(int skip = 1); // "%{public}s" argument to os_log. When os_log is not available we // fall back to NSLog (Objective-C) or CF/NSLog bridge in pure C++ builds. +// Whether split messages should carry markers for the CLI to stitch them with. +bool ShouldMarkLogChunks(); + +#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 = "") { + ForEachLogChunk(message, kMaxLogChunkBytes - prefix.size(), + [&](std::string_view chunk, LogChunkBreak chunkBreak) { + const char* marker = ""; + if (chunkBreak != LogChunkBreak::kNone && ShouldMarkLogChunks()) { + marker = chunkBreak == LogChunkBreak::kLine ? kLogChunkLineMarker + : kLogChunkJoinMarker; + } + os_log_with_type(OS_LOG_DEFAULT, type, "%{public}.*s%{public}.*s%{public}s", + static_cast(prefix.size()), prefix.data(), + static_cast(chunk.size()), chunk.data(), marker); + }); +} +#endif + #ifdef __OBJC__ // Overload for Objective-C string literals (NSString*) static inline void TNS_FormatAndLog(NSString* fmt, ...) { @@ -373,7 +396,7 @@ static inline void TNS_FormatAndLog(NSString* fmt, ...) { const char* cStr = [formattedString UTF8String]; if (cStr) { #if TNS_HAVE_OS_LOG - os_log(OS_LOG_DEFAULT, "%{public}s", cStr); + TNS_WriteToOsLog(cStr); #else NSLog(@"%s", cStr); #endif @@ -406,7 +429,7 @@ static inline void TNS_FormatAndLog(const char* fmt, ...) { if (needed < STACK_BUF_SIZE) { // Message fit into stack buffer — single formatting pass. #if TNS_HAVE_OS_LOG - os_log(OS_LOG_DEFAULT, "%{public}s", stack_buf); + TNS_WriteToOsLog(stack_buf); #else // Fall back to NSLog. Use Objective-C API when compiling as ObjC++. #ifdef __OBJC__ @@ -421,7 +444,7 @@ static inline void TNS_FormatAndLog(const char* fmt, ...) { vsnprintf(buffer.data(), buffer.size(), fmt, ap); #if TNS_HAVE_OS_LOG - os_log(OS_LOG_DEFAULT, "%{public}s", buffer.data()); + TNS_WriteToOsLog(buffer.data()); #else // Fall back to NSLog. Use Objective-C API when compiling as ObjC++. #ifdef __OBJC__ diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 41d5361b..a486ca68 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -493,6 +493,27 @@ void WriteDebugLine(tns::LogCategory category, const char* message) { } } +static bool IsDevNull(int fd) { + struct stat target; + struct stat devNull; + return fstat(fd, &target) == 0 && stat("/dev/null", &devNull) == 0 && + S_ISCHR(target.st_mode) && target.st_rdev == devNull.st_rdev; +} + +// Syslog-style output shows the markers as visible text and an attached console +// shows them as they are, so they need a CLI that asked for them and no console. +bool tns::ShouldMarkLogChunks() { + if (RuntimeConfig.ApplicationPath.empty()) { + return false; + } + + static const bool enabled = [] { + id value = Runtime::GetAppConfigValue("logChunkMarkers"); + return value != nil && [value boolValue] && IsDevNull(STDOUT_FILENO); + }(); + return enabled; +} + void tns::EmitDebugLog(LogCategory category, const char* format, ...) { va_list ap; va_start(ap, format); diff --git a/NativeScript/runtime/LogChunks.h b/NativeScript/runtime/LogChunks.h new file mode 100644 index 00000000..54c772f7 --- /dev/null +++ b/NativeScript/runtime/LogChunks.h @@ -0,0 +1,55 @@ +#ifndef LogChunks_h +#define LogChunks_h + +#include +#include + +namespace tns { + +// os_log replaces whatever exceeds its buffer with "<…>". The buffer also holds +// a few bytes of bookkeeping per argument, which left 996 bytes of text with two +// arguments on iPadOS 26.7, so stay well below that. +constexpr size_t kMaxLogChunkBytes = 900; + +// How a chunk connects to the one after it. +enum class LogChunkBreak { kNone, kJoin, kLine }; + +// Invisible, so that the chunks read normally wherever they are not stitched. +// Control characters would not do: os_log escapes them. +constexpr const char* kLogChunkJoinMarker = "\u2060"; +constexpr const char* kLogChunkLineMarker = "\u2061"; + +// Calls emit with consecutive pieces of message, each at most maxBytes long and +// never ending inside a UTF-8 sequence. A piece ends at the last line break that +// fits, so that lines stay intact whenever they can; that line break is dropped, +// since every piece is logged as a line of its own. +template +void ForEachLogChunk(std::string_view message, size_t maxBytes, Emit&& emit) { + while (message.size() > maxBytes) { + size_t end = message.rfind('\n', maxBytes); + size_t next = end + 1; + LogChunkBreak chunkBreak = LogChunkBreak::kLine; + + if (end == std::string_view::npos || end == 0) { + end = maxBytes; + while (end > 0 && (static_cast(message[end]) & 0xC0) == 0x80) { + end--; + } + // Not UTF-8 at all: any cut is as good as another. + if (end == 0) { + end = maxBytes; + } + next = end; + chunkBreak = LogChunkBreak::kJoin; + } + + emit(message.substr(0, end), chunkBreak); + message.remove_prefix(next); + } + + emit(message, LogChunkBreak::kNone); +} + +} // namespace tns + +#endif /* LogChunks_h */ diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 40102693..7034da3d 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -818,6 +818,7 @@ C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ArgConverter.mm; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1497,6 +1498,7 @@ C266569D22B282BA00EE15CC /* FunctionReference.h */, C266569C22B282BA00EE15CC /* FunctionReference.cpp */, C2DDEB6D229EAC8200345BFE /* Helpers.h */, + A1C0DE5100000000000000A1 /* LogChunks.h */, C2DDEB7D229EAC8200345BFE /* Helpers.mm */, AA5DBFD12EC191F8008D12F9 /* HttpLoader.h */, AA5DBFD22EC191F8008D12F9 /* HttpLoader.mm */, From db9cadde20b8dede874d6194dd6a1428d4007815 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:27 +0100 Subject: [PATCH 2/3] fix(console): ignore a logChunkMarkers value that isn't a boolean boolValue raises on NSNull, arrays and dictionaries, which would abort the app on its first split message. --- NativeScript/runtime/Helpers.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index a486ca68..e9bf22ad 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -509,7 +509,8 @@ static bool IsDevNull(int fd) { static const bool enabled = [] { id value = Runtime::GetAppConfigValue("logChunkMarkers"); - return value != nil && [value boolValue] && IsDevNull(STDOUT_FILENO); + return [value isKindOfClass:[NSNumber class]] && [value boolValue] && + IsDevNull(STDOUT_FILENO); }(); return enabled; } From e5e47e4aae8833cb9f41d5be2c199772774c5744 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 7 Nov 2026 16:50:34 -0300 Subject: [PATCH 3/3] feat(console): frame split messages with a header the CLI asks for Replaces the invisible end-of-chunk markers and the logChunkMarkers key. When a message has to be split and the app's package.json has a logFormat of 1 or more (with stdout going to /dev/null, as when the CLI launches the app), each piece is logged as "[ns /] ". The id is unique per message in the process. The pieces are consecutive slices of the message and only the first carries the console prefix, so they join back as they are. The unified log strips whitespace from the end of every entry, so a piece never ends in whitespace: the cut moves before it and the whitespace starts the next piece, behind its header. With the id, index and count, the CLI can tell pieces apart by message instead of by thread, notice a lost piece instead of joining the next message onto an unfinished one, and print a message as soon as its last piece arrives. logFormat holds the newest format the CLI understands; the runtime uses the lower of that and its own. Without the key, split messages are logged as before: plain lines, each with the prefix repeated. --- NativeScript/runtime/Helpers.h | 42 +++++++++----- NativeScript/runtime/Helpers.mm | 26 ++++++--- NativeScript/runtime/LogChunks.h | 99 ++++++++++++++++++++++++++++++-- 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 0eddc294..e30d3b90 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -355,24 +355,38 @@ void LogBacktrace(int skip = 1); // "%{public}s" argument to os_log. When os_log is not available we // fall back to NSLog (Objective-C) or CF/NSLog bridge in pure C++ builds. -// Whether split messages should carry markers for the CLI to stitch them with. -bool ShouldMarkLogChunks(); +// The log format (see LogChunks.h) the CLI asked for, or 0 for plain split lines. +int LogFormat(); + +// Unique per split message in the process, for the headers of format 1. +uint32_t NextLogMessageId(); #if TNS_HAVE_OS_LOG -// The prefix is repeated on every chunk, so that each stays a complete log line. +// Logs the two parts as one entry. +static inline void TNS_WriteOsLogEntry(os_log_type_t type, std::string_view first, + std::string_view second) { + os_log_with_type(OS_LOG_DEFAULT, type, "%{public}.*s%{public}.*s", + static_cast(first.size()), first.data(), static_cast(second.size()), + second.data()); +} + static inline void TNS_WriteToOsLog(const char* message, os_log_type_t type = OS_LOG_TYPE_DEFAULT, std::string_view prefix = "") { - ForEachLogChunk(message, kMaxLogChunkBytes - prefix.size(), - [&](std::string_view chunk, LogChunkBreak chunkBreak) { - const char* marker = ""; - if (chunkBreak != LogChunkBreak::kNone && ShouldMarkLogChunks()) { - marker = chunkBreak == LogChunkBreak::kLine ? kLogChunkLineMarker - : kLogChunkJoinMarker; - } - os_log_with_type(OS_LOG_DEFAULT, type, "%{public}.*s%{public}.*s%{public}s", - static_cast(prefix.size()), prefix.data(), - static_cast(chunk.size()), chunk.data(), marker); - }); + std::string_view text(message); + if (prefix.size() + text.size() <= kMaxLogChunkBytes) { + TNS_WriteOsLogEntry(type, prefix, text); + } else if (LogFormat() >= 1) { + ForEachFramedLogChunk(prefix, text, NextLogMessageId(), + [&](std::string_view header, std::string_view piece) { + TNS_WriteOsLogEntry(type, header, piece); + }); + } else { + // Repeated on every piece, so that each stays a complete log line. + ForEachLogChunk(text, kMaxLogChunkBytes - prefix.size(), + [&](std::string_view chunk, LogChunkBreak) { + TNS_WriteOsLogEntry(type, prefix, chunk); + }); + } } #endif diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index e9bf22ad..e95f2d80 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -500,19 +501,26 @@ static bool IsDevNull(int fd) { S_ISCHR(target.st_mode) && target.st_rdev == devNull.st_rdev; } -// Syslog-style output shows the markers as visible text and an attached console -// shows them as they are, so they need a CLI that asked for them and no console. -bool tns::ShouldMarkLogChunks() { +// A console attached to the app would show the headers, so they also need stdout +// to go nowhere, as it does when the CLI launches the app. +int tns::LogFormat() { if (RuntimeConfig.ApplicationPath.empty()) { - return false; + return 0; } - static const bool enabled = [] { - id value = Runtime::GetAppConfigValue("logChunkMarkers"); - return [value isKindOfClass:[NSNumber class]] && [value boolValue] && - IsDevNull(STDOUT_FILENO); + static const int format = [] { + id value = Runtime::GetAppConfigValue("logFormat"); + if (![value isKindOfClass:[NSNumber class]] || !IsDevNull(STDOUT_FILENO)) { + return 0; + } + return std::clamp([value intValue], 0, kNewestLogFormat); }(); - return enabled; + return format; +} + +uint32_t tns::NextLogMessageId() { + static std::atomic lastMessageId{0}; + return lastMessageId.fetch_add(1, std::memory_order_relaxed) + 1; } void tns::EmitDebugLog(LogCategory category, const char* format, ...) { diff --git a/NativeScript/runtime/LogChunks.h b/NativeScript/runtime/LogChunks.h index 54c772f7..4e8e8906 100644 --- a/NativeScript/runtime/LogChunks.h +++ b/NativeScript/runtime/LogChunks.h @@ -2,7 +2,11 @@ #define LogChunks_h #include +#include +#include +#include #include +#include namespace tns { @@ -14,11 +18,6 @@ constexpr size_t kMaxLogChunkBytes = 900; // How a chunk connects to the one after it. enum class LogChunkBreak { kNone, kJoin, kLine }; -// Invisible, so that the chunks read normally wherever they are not stitched. -// Control characters would not do: os_log escapes them. -constexpr const char* kLogChunkJoinMarker = "\u2060"; -constexpr const char* kLogChunkLineMarker = "\u2061"; - // Calls emit with consecutive pieces of message, each at most maxBytes long and // never ending inside a UTF-8 sequence. A piece ends at the last line break that // fits, so that lines stay intact whenever they can; that line break is dropped, @@ -50,6 +49,96 @@ void ForEachLogChunk(std::string_view message, size_t maxBytes, Emit&& emit) { emit(message, LogChunkBreak::kNone); } +// The newest value of the logFormat key in the app's package.json that the +// runtime understands. The CLI writes the newest one it understands. +// +// Format 1 logs each piece of a split message as "[ns /] " +// followed by the piece. is unique per message in the process (hex) and +// counts from 1. The pieces are consecutive slices of the message and +// only the first carries the message's prefix, so they join back into the +// message as they are. Messages that fit in one entry are logged without a +// header. +constexpr int kNewestLogFormat = 1; + +// Room for the longest header: ids up to 8 hex digits and counts up to 7 +// decimal ones (messages of several GB). +constexpr size_t kMaxLogChunkHeaderBytes = 40; + +inline std::string_view FormatLogChunkHeader(char (&buffer)[kMaxLogChunkHeaderBytes], uint32_t id, + size_t index, size_t count) { + int length = snprintf(buffer, sizeof(buffer), "[ns %x %zu/%zu] ", id, index, count); + if (length < 0) { + return {}; + } + return std::string_view(buffer, static_cast(length) < sizeof(buffer) + ? static_cast(length) + : sizeof(buffer) - 1); +} + +inline bool IsLogWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'; +} + +// Calls emit with consecutive slices of text, each at most maxBytes long and +// never ending inside a UTF-8 sequence. The unified log strips whitespace from +// the end of every entry, so a slice never ends in whitespace: it is left for +// the start of the next slice, behind that slice's header. Only a run of +// whitespace longer than maxBytes still loses what it ends with. A slice ends +// before the last line break that fits, so that lines stay intact whenever they +// can. +template +void ForEachLogSlice(std::string_view text, size_t maxBytes, Emit&& emit) { + auto backOverWhitespace = [&](size_t end) { + while (end > 0 && IsLogWhitespace(text[end - 1])) { + end--; + } + return end; + }; + + while (text.size() > maxBytes) { + size_t lineBreak = text.rfind('\n', maxBytes); + size_t end = lineBreak == std::string_view::npos ? 0 : backOverWhitespace(lineBreak); + + if (end == 0) { + end = maxBytes; + while (end > 0 && (static_cast(text[end]) & 0xC0) == 0x80) { + end--; + } + // Not UTF-8 at all: any cut is as good as another. + if (end == 0) { + end = maxBytes; + } + if (size_t beforeWhitespace = backOverWhitespace(end)) { + end = beforeWhitespace; + } + } + + emit(text.substr(0, end)); + text.remove_prefix(end); + } + + emit(text); +} + +// Calls emit with the header and text of each piece of prefix + message, split +// in format 1 as the message with the given id. +template +void ForEachFramedLogChunk(std::string_view prefix, std::string_view message, uint32_t id, + Emit&& emit) { + std::string text; + text.reserve(prefix.size() + message.size()); + text.append(prefix).append(message); + + std::vector pieces; + ForEachLogSlice(text, kMaxLogChunkBytes - kMaxLogChunkHeaderBytes, + [&](std::string_view piece) { pieces.push_back(piece); }); + + for (size_t i = 0; i < pieces.size(); i++) { + char buffer[kMaxLogChunkHeaderBytes]; + emit(FormatLogChunkHeader(buffer, id, i + 1, pieces.size()), pieces[i]); + } +} + } // namespace tns #endif /* LogChunks_h */