///|
/// Synchronous diagnostics write over the raw `write(2)` symbol. The runtime
/// trace seam is a plain synchronous closure, so the default stderr sink
/// cannot go through the async event loop; it goes straight to libc instead,
/// exactly like the `close` binding in `moonbitlang/async` itself. The buffer
/// is borrowed: `write(2)` reads it during the call and never stores it.
#borrow(buffer)
extern "C" fn runtime_libc_write_ffi(
fd : Int,
buffer : Bytes,
count : Int,
) -> Int = "write"
///|
/// Public wrapper over the raw diagnostics writer so the blackbox suite can
/// prove the byte path against a real pipe. Not exported through the stable
/// facade.
pub fn runtime_libc_write(fd : Int, buffer : Bytes, count : Int) -> Int {
runtime_libc_write_ffi(fd, buffer, count)
}
///|
/// File descriptor of the standard error stream on every POSIX target.
let runtime_stderr_fd : Int = 2
///|
/// Reduce one trace field to printable ASCII. Any control character,
/// including a newline smuggled into a peer-supplied request id or method
/// name, becomes `?`, so one trace event can never be forged into several
/// lines or into frame-like content on the diagnostics channel.
fn runtime_trace_sanitize(value : String) -> String {
let buffer = StringBuilder::new()
for ch in value {
if ch >= '\u{20}' && ch <= '\u{7e}' {
buffer.write_char(ch)
} else {
buffer.write_char('?')
}
}
buffer.to_string()
}
///|
/// Render one redacted trace event as exactly one diagnostics line ending in
/// a single newline. The five trace fields keep their fixed order; every
/// value is sanitized first. This renderer is the exact text the default
/// stderr sink writes, which keeps the diagnostics format unit-testable.
pub fn runtime_trace_stderr_line(event : RuntimeTraceEvent) -> String {
"acp direction=" +
runtime_trace_sanitize(event.direction) +
" phase=" +
runtime_trace_sanitize(event.phase) +
" request_id=" +
runtime_trace_sanitize(event.request_id) +
" method=" +
runtime_trace_sanitize(event.method_name) +
" error=" +
runtime_trace_sanitize(event.error_kind) +
"\n"
}
///|
/// Static kind text for one outbound failure category. The closed stable
/// `RuntimeError` set has no per-failure-kind strings, so the sink carries the
/// precise category here the same way the connection runtime carries it in
/// its own trace kinds.
fn runtime_outbound_failure_kind_text(
kind : RuntimeOutboundFailureKind,
) -> String {
match kind {
Protocol => "protocol"
Framing => "framing"
Reader => "reader"
Writer => "writer"
Handler => "handler"
Notification => "notification"
EndOfInput => "end_of_input"
EventQueue => "event_queue"
Runtime => "runtime"
}
}
///|
/// Best-effort synchronous write of one diagnostics record to a file
/// descriptor. Returns the number of bytes accepted by the OS. The sink has
/// no failure channel by type, so the raw result stays observable for tests
/// instead of being swallowed.
fn runtime_trace_write_line(fd : Int, line : String) -> Int {
let data = @utf8.encode(line)
runtime_libc_write(fd, data, data.length())
}
///|
/// Default trace sink: one `write(2)` per event to the real standard error.
/// Stdout is never touched here, so the protocol channel keeps carrying
/// newline-delimited ACP frames only. The sink type
/// `(RuntimeTraceEvent) -> Unit` has no failure channel: when the OS rejects a
/// diagnostics write (for example a closed stderr under a daemon supervisor),
/// the event is dropped without inventing a failure the trace contract cannot
/// carry. Protocol I/O failures stay fully typed at the reader/writer seams.
pub let runtime_stderr_trace : (RuntimeTraceEvent) -> Unit = event => {
ignore(
runtime_trace_write_line(
runtime_stderr_fd,
runtime_trace_stderr_line(event),
),
)
}
///|
/// Serve the native ACP stdio boundary of this process. The reader reads the
/// real stdin chunk-wise (`None` is EOF, non-empty chunks only, exactly the
/// `RuntimeReaderPort` contract). The writer writes each complete
/// newline-delimited frame to the real stdout in one `Output::write` call:
/// `@stdio.Output` wraps the raw file descriptor with no userspace buffering
/// (`moonbitlang/async/src/stdio/stdio.mbt`), so every write is handed to the
/// operating system immediately — the pinned flush-per-write discipline holds
/// structurally because no buffered writer layer exists on this path.
///
/// The handler seams exist only for engine runners with an outbound channel.
/// `response` and `outbound_failure` are deliberate no-op observers: reply
/// delivery to parked submitters is owned by the engine's outbound channel,
/// never by these callbacks. The legacy dispatch seams (`request`,
/// `notification`) abort fail-fast: these ports exist for owner-loop runners,
/// and a legacy handler dispatch through them would silently answer nothing,
/// so it must crash loudly instead.
///
/// Diagnostics go to the trace sink, defaulting to stderr. Nothing in this
/// constructor writes to stdout, and it performs no I/O at all beyond binding
/// the process file descriptors.
pub fn runtime_stdio_ports(
trace? : (RuntimeTraceEvent) -> Unit = runtime_stderr_trace,
) -> RuntimePorts {
{
reader: { read: max_len => @stdio.stdin.read_some(max_len~) },
writer: { write: frame => @stdio.stdout.write(frame) },
handlers: {
request: _ => {
abort(
"runtime stdio ports serve owner-loop runners; legacy request dispatch is unreachable",
)
},
notification: _ => {
abort(
"runtime stdio ports serve owner-loop runners; legacy notification dispatch is unreachable",
)
},
response: _ => (),
outbound_failure: Some((id, failure) => {
trace(
runtime_trace_event(
"outbound",
"outbound_failed",
runtime_request_id_text(id),
"",
runtime_outbound_failure_kind_text(failure.reason),
),
)
}),
},
cancel_outbound: None,
trace,
}
}