From f3e72fad2da4deb818e39472ccc138cb79522b0a Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Sat, 10 Oct 2026 21:34:29 +0800 Subject: [PATCH] fix: make embedded diagnostics opt-in --- CHANGELOG.md | 4 ++++ docs/diagnostics.md | 17 +++++++++++------ .../RivetEmbedding/EmbeddedRacketBackend.swift | 4 ++-- .../macos/Sources/RivetRuntime/Client.swift | 2 +- .../Sources/RivetRuntime/Diagnostics.swift | 2 ++ rivet/backend.rkt | 11 +++++------ runtime/include/rivet/diagnostics.hpp | 7 ++++--- runtime/tests/protocol_test.cpp | 5 +++++ tests/backend-diagnostics.rkt | 7 +++++++ 9 files changed, 41 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6c01b7..95d0772b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Make embedded diagnostics opt-in across Racket, C++, and Swift. GUI hosts no + longer write to standard error by default, preventing Windows applications + from allocating a black console window on their first diagnostic; explicit + structured-log and stderr sinks remain supported. - Add first-party portable update installation for Windows, macOS, and Linux: verified ZIP/AppImage payloads are staged, platform-signature checked, atomically replaced, health-gated, durably committed, and recoverable after diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 27635420..42e6a190 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -109,9 +109,11 @@ closure, reader-loop failure, and backend exit. A failure record therefore says whether the last known boundary was the Racket backend, the ABI bridge, RVT1 validation, transport I/O, or the native client. -By default, embedded applications write JSONL to standard error. Applications -can redirect records to their own logger or crash reporter without adding a -Rivet logging dependency: +Embedded applications are silent by default. This is important for native GUI +processes: on Windows, the first standard-error write can allocate a visible +console window. Applications should install a sink backed by their own logger +or crash reporter. The native runtimes keep explicit JSONL-to-stderr helpers +for command-line tools and development sessions: ```cpp rivet::windows::RacketRuntimeConfig config; @@ -122,9 +124,12 @@ config.diagnostic_sink = [](rivet::DiagnosticRecord const& record) { The same `diagnostic_sink` field is available in the Linux runtime config. On Apple platforms, pass `diagnosticSink:` to -`EmbeddedRacketConfiguration.resolvedDefault` or `RivetClient`. On the Racket -side, `serve-fds` uses `current-rivet-diagnostic-sink`; direct `serve` callers -can pass `#:diagnostic-sink` explicitly. +`EmbeddedRacketConfiguration.resolvedDefault` or `RivetClient`; use +`RivetDiagnostics.standardError` only when stderr is intentional. On the +Racket side, `serve-fds` uses `current-rivet-diagnostic-sink`; direct `serve` +callers can pass `#:diagnostic-sink` explicitly. The parameter defaults to +`void`, so importing an embedded backend never acquires a console as a side +effect. Diagnostic sinks run on runtime and request threads. They should be fast, thread-safe, non-blocking, and must not call back into the same runtime. C++ diff --git a/platform/macos/Sources/RivetEmbedding/EmbeddedRacketBackend.swift b/platform/macos/Sources/RivetEmbedding/EmbeddedRacketBackend.swift index 869d719b..e3772e20 100644 --- a/platform/macos/Sources/RivetEmbedding/EmbeddedRacketBackend.swift +++ b/platform/macos/Sources/RivetEmbedding/EmbeddedRacketBackend.swift @@ -47,7 +47,7 @@ public struct EmbeddedRacketConfiguration: Sendable { moduleName: String = "backend", entryName: String = "start", maxPendingRequests: Int = 1024, - diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.standardError + diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.discard ) { precondition(maxPendingRequests > 0, "Rivet native pending request limit must be positive") self.executable = executable @@ -73,7 +73,7 @@ public struct EmbeddedRacketConfiguration: Sendable { moduleName: String = "backend", entryName: String = "start", maxPendingRequests: Int = 1024, - diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.standardError + diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.discard ) throws -> EmbeddedRacketConfiguration { let executable = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL diff --git a/platform/macos/Sources/RivetRuntime/Client.swift b/platform/macos/Sources/RivetRuntime/Client.swift index 647d6c38..2dfae8c4 100644 --- a/platform/macos/Sources/RivetRuntime/Client.swift +++ b/platform/macos/Sources/RivetRuntime/Client.swift @@ -137,7 +137,7 @@ public final class RivetClient: @unchecked Sendable { input: FileHandle, output: FileHandle, maxPendingRequests: Int = 1024, - diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.standardError + diagnosticSink: @escaping RivetDiagnosticSink = RivetDiagnostics.discard ) { precondition(maxPendingRequests > 0, "Rivet native pending request limit must be positive") self.input = input diff --git a/platform/macos/Sources/RivetRuntime/Diagnostics.swift b/platform/macos/Sources/RivetRuntime/Diagnostics.swift index ea391601..daddfdda 100644 --- a/platform/macos/Sources/RivetRuntime/Diagnostics.swift +++ b/platform/macos/Sources/RivetRuntime/Diagnostics.swift @@ -49,6 +49,8 @@ public typealias RivetDiagnosticSink = @Sendable (RivetDiagnosticRecord) -> Void public enum RivetDiagnostics { private static let outputLock = NSLock() + public static let discard: RivetDiagnosticSink = { _ in } + public static let standardError: RivetDiagnosticSink = { record in let data = Data((record.jsonLine() + "\n").utf8) outputLock.lock() diff --git a/rivet/backend.rkt b/rivet/backend.rkt index f9f5cfa0..700e0932 100644 --- a/rivet/backend.rkt +++ b/rivet/backend.rkt @@ -1,7 +1,6 @@ #lang racket/base (require ffi/unsafe/port - json racket/async-channel racket/list racket/match @@ -59,11 +58,11 @@ (define diagnostic-schema "rivet.diagnostic.v1") (define current-rivet-diagnostic-sink - (make-parameter - (lambda (record) - (write-json record (current-error-port)) - (newline (current-error-port)) - (flush-output (current-error-port))))) + ;; Embedded desktop processes do not necessarily own a console. In + ;; particular, the first stderr write from a Windows GUI-subsystem process + ;; can allocate a visible console window. Diagnostics are therefore opt-in; + ;; applications should install a sink backed by their structured logger. + (make-parameter void)) (define (safe-diagnostic-message raised) (define message diff --git a/runtime/include/rivet/diagnostics.hpp b/runtime/include/rivet/diagnostics.hpp index bcac9ead..bcbe2655 100644 --- a/runtime/include/rivet/diagnostics.hpp +++ b/runtime/include/rivet/diagnostics.hpp @@ -75,9 +75,10 @@ inline void write_diagnostic_to_stderr(DiagnosticRecord const& record) { } inline DiagnosticSink default_diagnostic_sink() { - return [](DiagnosticRecord const& record) { - write_diagnostic_to_stderr(record); - }; + // A desktop GUI process may not own a console. On Windows, writing to stderr + // can cause a console window to appear beside the application. Preserve the + // explicit stderr helper, but require products to opt into any log sink. + return {}; } } // namespace rivet diff --git a/runtime/tests/protocol_test.cpp b/runtime/tests/protocol_test.cpp index ce8206bc..1290eb05 100644 --- a/runtime/tests/protocol_test.cpp +++ b/runtime/tests/protocol_test.cpp @@ -264,5 +264,10 @@ int main() { "\"last_protocol_event\":\"request\\nread\",\"request_id\":42," "\"message\":\"quote: \\\" and control: \\u0001\"}"); + // Desktop applications opt into a sink. The framework default must not + // touch stderr because a Windows GUI-subsystem process can allocate a + // visible console on its first write. + assert(!rivet::default_diagnostic_sink()); + return 0; } diff --git a/tests/backend-diagnostics.rkt b/tests/backend-diagnostics.rkt index 1e0196f5..3c7102ff 100644 --- a/tests/backend-diagnostics.rkt +++ b/tests/backend-diagnostics.rkt @@ -9,6 +9,13 @@ (define diagnostic-records (box '())) (define diagnostic-lock (make-semaphore 1)) +;; The embedded default is deliberately silent. Writing to stderr can lazily +;; allocate a console for a Windows GUI-subsystem host. +(define default-output (open-output-string)) +(parameterize ([current-error-port default-output]) + ((current-rivet-diagnostic-sink) (hasheq 'event "must-not-print"))) +(check-equal? (get-output-string default-output) "") + (define (capture-diagnostic! record) (call-with-semaphore diagnostic-lock