Files
plainleaf/server/src/runtime/client.rs
T
2026-06-23 09:19:30 +02:00

222 lines
8.3 KiB
Rust

//! The shared upper layer, written and tested ONCE for every transport. It
//! builds the `sbRuntime.*` call snippet, delegates evaluation to the
//! transport, and serves the shared log buffer. Whatever the client function
//! returns is passed through verbatim — endpoint-specific shaping (e.g. the
//! objects string→JSON unwrap) lives in the handlers.
use std::time::Duration;
use super::backend::{RuntimeBackend, RuntimeError};
use super::logs::{LogBuffer, LogEntry};
use super::transport::ClientTransport;
/// Build the JS expression that invokes a global function with a single
/// JSON-encoded string argument. The transport awaits the returned promise and
/// extracts its JSON value.
///
/// `build_global_call_js("sbRuntime.evalLua", "1 + 1")` → `sbRuntime.evalLua("1 + 1")`.
pub fn build_global_call_js(fn_name: &str, arg: &str) -> String {
let arg_json = serde_json::to_string(arg).unwrap_or_else(|_| "\"\"".to_string());
format!("{fn_name}({arg_json})")
}
/// A `RuntimeBackend` for any `ClientTransport`. Holds the transport plus the
/// shared `LogBuffer` (the transport pushes console output into a clone of it).
pub struct ClientRuntime<T: ClientTransport> {
transport: T,
logs: LogBuffer,
}
impl<T: ClientTransport> ClientRuntime<T> {
/// `logs` must be the same buffer (clone) the transport pushes console
/// output into, so `/.runtime/logs` reflects what the client logged.
pub fn new(transport: T, logs: LogBuffer) -> Self {
Self { transport, logs }
}
}
impl<T: ClientTransport> RuntimeBackend for ClientRuntime<T> {
fn eval_global(
&self,
fn_name: &str,
arg: &str,
timeout: Duration,
) -> Result<serde_json::Value, RuntimeError> {
// Single choke point for every runtime API call (evalLua,
// evalLuaScript, objectsAPI), so a failure here — the runtime not
// coming up, a timeout, or a thrown error in the client (e.g. a Lua
// error) — always leaves a trace in the server log.
let result = self.transport.wait_ready(timeout).and_then(|()| {
self.transport
.eval_js(&build_global_call_js(fn_name, arg), timeout)
});
if let Err(e) = &result {
tracing::warn!("runtime call {fn_name} failed: {e}");
}
result
}
fn logs(&self, limit: usize, since: Option<i64>) -> Vec<LogEntry> {
// Reading logs counts as using the runtime: nudge a lazily-launched
// transport to boot so console output actually starts flowing. Without
// this, `sb logs` (especially `--follow`) against a freshly-started
// server that has had no eval yet would sit on an empty buffer forever.
self.transport.ensure_started();
self.logs.query(limit, since)
}
fn ready(&self) -> bool {
self.transport.is_ready()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
#[test]
fn builds_call_snippet_with_json_escaping() {
assert_eq!(
build_global_call_js("sbRuntime.evalLua", "1 + 1"),
r#"sbRuntime.evalLua("1 + 1")"#
);
// Quotes / newlines in the argument are JSON-escaped, not injected raw.
assert_eq!(
build_global_call_js("f", "print(\"hi\")\n"),
r#"f("print(\"hi\")\n")"#
);
}
/// A fake transport: records the JS it was asked to eval, returns a canned
/// result, and has configurable readiness.
struct FakeTransport {
ready: bool,
wait_result: Result<(), RuntimeError>,
eval_result: Result<serde_json::Value, RuntimeError>,
seen_js: Mutex<Vec<String>>,
started: AtomicBool,
}
impl FakeTransport {
fn ok(value: serde_json::Value) -> Self {
Self {
ready: true,
wait_result: Ok(()),
eval_result: Ok(value),
seen_js: Mutex::new(Vec::new()),
started: AtomicBool::new(false),
}
}
}
impl ClientTransport for FakeTransport {
fn eval_js(&self, js: &str, _timeout: Duration) -> Result<serde_json::Value, RuntimeError> {
self.seen_js.lock().unwrap().push(js.to_string());
self.eval_result
.as_ref()
.map(|v| v.clone())
.map_err(|e| match e {
RuntimeError::NotReady => RuntimeError::NotReady,
RuntimeError::Timeout => RuntimeError::Timeout,
RuntimeError::Transport(s) => RuntimeError::Transport(s.clone()),
RuntimeError::Eval(s) => RuntimeError::Eval(s.clone()),
})
}
fn wait_ready(&self, _timeout: Duration) -> Result<(), RuntimeError> {
match &self.wait_result {
Ok(()) => Ok(()),
Err(RuntimeError::NotReady) => Err(RuntimeError::NotReady),
Err(RuntimeError::Timeout) => Err(RuntimeError::Timeout),
Err(RuntimeError::Transport(s)) => Err(RuntimeError::Transport(s.clone())),
Err(RuntimeError::Eval(s)) => Err(RuntimeError::Eval(s.clone())),
}
}
fn is_ready(&self) -> bool {
self.ready
}
fn ensure_started(&self) {
self.started.store(true, Ordering::Relaxed);
}
}
#[test]
fn eval_global_calls_the_named_fn_and_passes_the_value_through() {
let logs = LogBuffer::new();
let envelope = serde_json::json!({ "result": 2 });
let rt = ClientRuntime::new(FakeTransport::ok(envelope.clone()), logs);
let out = rt
.eval_global("sbRuntime.evalLua", "1 + 1", Duration::from_secs(5))
.unwrap();
// The transport's value is returned verbatim (no shaping at this layer).
assert_eq!(out, envelope);
let seen = rt.transport.seen_js.lock().unwrap();
assert_eq!(seen[0], r#"sbRuntime.evalLua("1 + 1")"#);
}
#[test]
fn eval_global_returns_raw_transport_value_including_strings() {
// The objects endpoint relies on a String return passing through
// unmodified (the handler does its own unwrap) — verify no shaping here.
let logs = LogBuffer::new();
let raw = serde_json::json!(r#"{"ok":true}"#);
let rt = ClientRuntime::new(FakeTransport::ok(raw.clone()), logs);
let out = rt
.eval_global("sbRuntime.objectsAPI", "{}", Duration::from_secs(5))
.unwrap();
assert_eq!(out, raw);
let seen = rt.transport.seen_js.lock().unwrap();
assert!(
seen[0].starts_with("sbRuntime.objectsAPI("),
"got: {}",
seen[0]
);
}
#[test]
fn not_ready_short_circuits_before_eval() {
let logs = LogBuffer::new();
let mut transport = FakeTransport::ok(serde_json::json!(null));
transport.wait_result = Err(RuntimeError::NotReady);
let rt = ClientRuntime::new(transport, logs);
let err = rt
.eval_global("sbRuntime.evalLua", "x", Duration::from_secs(1))
.unwrap_err();
assert!(matches!(err, RuntimeError::NotReady));
// eval_js must NOT have been called.
assert!(rt.transport.seen_js.lock().unwrap().is_empty());
}
#[test]
fn logs_are_read_from_the_shared_buffer() {
let logs = LogBuffer::new();
logs.push(LogEntry {
level: "log".into(),
text: "hello".into(),
timestamp: 1,
});
let rt = ClientRuntime::new(FakeTransport::ok(serde_json::json!(null)), logs);
let got = rt.logs(100, None);
assert_eq!(got.len(), 1);
assert_eq!(got[0].text, "hello");
}
#[test]
fn logs_nudges_a_lazy_transport_to_start() {
// Reading logs must boot a lazily-launched runtime so console output
// starts flowing (the `sb logs`-on-a-fresh-server fix).
let logs = LogBuffer::new();
let rt = ClientRuntime::new(FakeTransport::ok(serde_json::json!(null)), logs);
let _ = rt.logs(100, None);
assert!(rt.transport.started.load(Ordering::Relaxed));
}
#[test]
fn ready_delegates_to_transport() {
let logs = LogBuffer::new();
let rt = ClientRuntime::new(FakeTransport::ok(serde_json::json!(null)), logs);
assert!(rt.ready());
}
}