Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Replace the accidental public backend registry structs and mutable State
cell accessors with an opaque `state?` handle and a single immutable
`backend-schema` reflection snapshot. Pre-1.0 tooling that used
Expand Down
17 changes: 11 additions & 6 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion platform/macos/Sources/RivetRuntime/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions platform/macos/Sources/RivetRuntime/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 5 additions & 6 deletions rivet/backend.rkt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#lang racket/base

(require ffi/unsafe/port
json
racket/async-channel
racket/list
racket/match
Expand Down Expand Up @@ -56,11 +55,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
Expand Down
7 changes: 4 additions & 3 deletions runtime/include/rivet/diagnostics.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions runtime/tests/protocol_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions tests/backend-diagnostics.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading