Chrome pool redo: reuses a single (headless) chrome for all spaces in the server, not one per instance

This commit is contained in:
Zef Hemel
2026-07-31 14:36:55 +02:00
parent a70b83ecc2
commit eebd5facc7
21 changed files with 1614 additions and 466 deletions
+43 -2
View File
@@ -106,6 +106,47 @@ jobs:
npm run check
npm test
# Core's Rust suite, including the headless-Chrome runtime e2e tests. The
# client bundle has to exist on disk first: rust-embed serves from disk in
# debug builds, so without it the server has no client to hand the headless
# page.
test-rust:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: Swatinem/rust-cache@v2
with:
key: test-rust
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install dependencies
run: npm ci
- name: Build client bundle
run: npm run build
- name: Install Playwright browser (chromium)
run: npx playwright install --with-deps chromium
# The runtime tests skip themselves when no browser is found, which would
# silently gut this job. Point them (and the servers they spawn) at
# Playwright's chromium explicitly: their skip guard reads CHROMIUM_PATH
# with the same precedence the server does, and refuses to skip at all
# when CI is set — so this job can no longer pass having exercised nothing.
- name: Resolve chromium path
run: |
echo "CHROMIUM_PATH=$(node -e "console.log(require('@playwright/test').chromium.executablePath())")" >> "$GITHUB_ENV"
- name: Run Rust tests
run: cargo test --workspace --all-features
test-e2e:
runs-on: ubuntu-latest
steps:
@@ -197,7 +238,7 @@ jobs:
secrets: inherit
release:
needs: [config, build, test-frontend, test-e2e, test-e2e-release]
needs: [config, build, test-frontend, test-rust, test-e2e, test-e2e-release]
if: needs.config.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
@@ -273,7 +314,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
needs: [config, build, test-frontend, test-e2e, test-e2e-release]
needs: [config, build, test-frontend, test-rust, test-e2e, test-e2e-release]
if: needs.config.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
Generated
+1
View File
@@ -2212,6 +2212,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"silverbullet-server-common",
"silverbullet-server-runtime-chrome",
"tempfile",
"uuid",
]
+8
View File
@@ -31,3 +31,11 @@ serde_json = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
# Only for `find_chrome()`, so the runtime e2e test skips cleanly on machines
# without a browser instead of failing.
silverbullet-server-runtime-chrome = { path = "../../server-runtime-chrome" }
# `cookies` is declared here rather than relied on from workspace feature
# unification: `bin/silverbullet` enables it, which is enough for a whole-
# workspace build but leaves `cargo clippy -p sb` without
# `ClientBuilder::cookie_store` (used by the multi-space runtime e2e test).
reqwest = { workspace = true, features = ["cookies"] }
+334
View File
@@ -0,0 +1,334 @@
//! End-to-end test for the Runtime API in **multi-space** mode, driven through
//! the real `sb` CLI.
//!
//! Two spaces are created on one authenticated server — one bound at the root
//! prefix, one at `/notes` — each holding a different marker page. The test then
//! evaluates Lua in each space through `sb eval` and asserts each answers with
//! its *own* content. That is the assertion that matters: a shared browser must
//! not collapse two spaces into one client.
//!
//! It also asserts the shared browser is genuinely shared, by requiring the
//! server to log its launch line exactly once across both spaces.
//!
//! Skips cleanly when Chrome is absent or when the sibling `silverbullet`
//! server binary hasn't been built (run `cargo test --workspace`, which builds
//! every bin) — except under `CI`, where either missing prerequisite is a hard
//! failure rather than a silent skip (see `common::chrome_available_or_skip`
//! and `server_bin_or_skip`).
use std::io::Read;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const ADMIN_USER: &str = "admin";
const ADMIN_PASSWORD: &str = "adminpw1";
#[path = "../../silverbullet/tests/common/mod.rs"]
mod common;
use common::{chrome_available_or_skip, free_port};
/// Wraps the spawned server and kills it (plus the shared Chrome it owns) on
/// drop, so nothing leaks even if an assertion panics.
///
/// stdout/stderr are drained continuously by background threads into `log`,
/// rather than read once at the end. Two headless clients (root + notes) each
/// forward their full `console.*` output into the server's log at INFO level
/// (see `ChromeConfig::log_console`), which is enough volume to fill the OS
/// pipe buffer within seconds; an unread `Stdio::piped()` handle would then
/// make the server block on its own log write, wedging the whole process —
/// including the HTTP listener — with no exception or panic to show for it.
struct Server {
child: Child,
log: Arc<Mutex<String>>,
readers: Vec<std::thread::JoinHandle<()>>,
}
/// Continuously copy `reader` into `log` until EOF/error (the process exited
/// and closed the pipe).
fn drain(mut reader: impl Read, log: Arc<Mutex<String>>) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(n) => log
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_str(&String::from_utf8_lossy(&buf[..n])),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Server {
/// Spawn `cmd` with piped stdout/stderr, immediately handing both to
/// dedicated reader threads so the child never blocks on a full pipe.
fn spawn(mut cmd: Command) -> Self {
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn silverbullet");
let log = Arc::new(Mutex::new(String::new()));
let mut readers = Vec::with_capacity(2);
if let Some(out) = child.stdout.take() {
let log = log.clone();
readers.push(std::thread::spawn(move || drain(out, log)));
}
if let Some(err) = child.stderr.take() {
let log = log.clone();
readers.push(std::thread::spawn(move || drain(err, log)));
}
Server {
child,
log,
readers,
}
}
/// Kill the server and return everything captured. Joining the reader
/// threads (rather than sleeping a guessed amount) blocks exactly until
/// each has drained its pipe to EOF, which follows promptly once `wait()`
/// confirms the process has exited and closed its ends.
fn finish(mut self) -> String {
let _ = self.child.kill();
let _ = self.child.wait();
for r in self.readers.drain(..) {
let _ = r.join();
}
self.log.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
fn sb_bin() -> &'static str {
env!("CARGO_BIN_EXE_sb")
}
/// The sibling `silverbullet` server binary in the same target dir, if built.
///
/// `None` means this test cannot run at all. Under `CI` that is a hard failure
/// rather than a silent skip, for the same reason as
/// `common::chrome_available_or_skip`: this job gates the release and docker
/// publishes, and a skipped run reads as a pass. It cannot trigger under the
/// current CI command (`cargo test --workspace --all-features` builds every bin
/// into the probed target dir), which is precisely why a silent skip here would
/// go unnoticed the day somebody narrows that command.
fn server_bin_or_skip() -> Option<PathBuf> {
let dir = PathBuf::from(sb_bin()).parent()?.to_path_buf();
let name = if cfg!(windows) {
"silverbullet.exe"
} else {
"silverbullet"
};
let p = dir.join(name);
if p.exists() {
return Some(p);
}
if std::env::var("CI").is_ok() {
panic!(
"runtime_multi_e2e: no `silverbullet` server binary at {} — this test drives the \
real server, so it cannot run without one. Refusing to skip in CI: run the suite as \
`cargo test --workspace --all-features`, which builds every bin into that directory.",
p.display()
);
}
eprintln!("skipping runtime_multi_e2e: silverbullet server binary not built");
None
}
/// Run `sb` with an isolated config home and capture stdout/stderr/exit code.
fn run_sb(args: &[&str], config_home: &std::path::Path) -> (i32, String, String) {
let out = Command::new(sb_bin())
.args(args)
.env("XDG_CONFIG_HOME", config_home)
.output()
.expect("spawn sb");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn runtime_api_serves_two_spaces_from_one_shared_chrome() {
if !chrome_available_or_skip("runtime_multi_e2e") {
return;
}
let Some(server) = server_bin_or_skip() else {
return;
};
let root = tempfile::tempdir().unwrap();
let sb_config = tempfile::tempdir().unwrap();
let chrome_data = root.path().join(".chrome-data");
// Provision the root through the real `setup` subcommand: admin account +
// spaces.json, no first space (both spaces are created over the admin API).
let out = Command::new(&server)
.arg("setup")
.arg(root.path())
.arg("--admin")
.arg(format!("{ADMIN_USER}:{ADMIN_PASSWORD}"))
.output()
.expect("spawn silverbullet setup");
assert!(
out.status.success(),
"setup failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let port = free_port();
let mut cmd = Command::new(&server);
cmd.arg(root.path())
.args(["-p", &port.to_string(), "-L", "127.0.0.1"])
.env_remove("SB_MULTI_SPACE")
.env_remove("SB_USER")
.env_remove("SB_RUNTIME_API")
.env("SB_DISABLE_SERVICE_WORKER", "1")
.env("SB_CHROME_DATA_DIR", &chrome_data);
let server_proc = Server::spawn(cmd);
let base = format!("http://127.0.0.1:{port}");
// Wait for the multi stack: the admin API answers 401 once it's mounted.
let probe = reqwest::blocking::Client::new();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(r) = probe.get(format!("{base}/.spaces/api/admin/spaces")).send() {
if r.status().as_u16() == 401 {
break;
}
}
assert!(Instant::now() < deadline, "server did not boot in time");
std::thread::sleep(Duration::from_millis(200));
}
// Log in as the admin.
let admin = reqwest::blocking::Client::builder()
.cookie_store(true)
.build()
.unwrap();
let r = admin
.post(format!("{base}/.spaces/api/login"))
.json(&serde_json::json!({ "username": ADMIN_USER, "password": ADMIN_PASSWORD }))
.send()
.unwrap();
assert!(r.status().is_success(), "admin login failed");
// Two spaces with the runtime API on. One is bound at the ROOT prefix, so
// its auth cookie is set with Path=/ and rides along on the other space's
// requests — exactly the case per-space cookie names exist to handle.
for (name, prefix, folder) in [
("Root", "/", "spaceRoot"),
("Notes", "/notes", "spaceNotes"),
] {
let r = admin
.post(format!("{base}/.spaces/api/admin/spaces"))
.json(&serde_json::json!({
"name": name,
"folder": folder,
"binding": { "prefix": prefix },
"runtimeApi": true
}))
.send()
.unwrap();
assert!(
r.status().is_success(),
"creating space {name} failed: {}",
r.text().unwrap_or_default()
);
}
// A distinguishing page per space, written before any runtime call so the
// headless client picks it up on its initial sync.
std::fs::write(
root.path().join("spaceRoot").join("Marker.md"),
"marker-from-root-space\n",
)
.unwrap();
std::fs::write(
root.path().join("spaceNotes").join("Marker.md"),
"marker-from-notes-space\n",
)
.unwrap();
// An admin API token for `sb --token`.
let r = admin
.post(format!(
"{base}/.spaces/api/admin/users/{ADMIN_USER}/tokens"
))
.json(&serde_json::json!({ "name": "e2e" }))
.send()
.unwrap();
assert!(r.status().is_success(), "token creation failed");
let token = r.json::<serde_json::Value>().unwrap()["token"]
.as_str()
.expect("token in response")
.to_string();
// Anonymous `sb` must be rejected.
let (code, _, stderr) = run_sb(&["--url", &base, "eval", "1 + 1"], sb_config.path());
assert_ne!(code, 0, "anonymous eval should fail");
assert!(
stderr.contains("401")
|| stderr.to_lowercase().contains("unauthor")
|| stderr.contains("authentication required"),
"expected an auth error, got: {stderr}"
);
// Each space answers with its own marker. The first call also pays for the
// browser launch and the client's first sync, so allow a generous window.
for (prefix, expected) in [
("", "marker-from-root-space"),
("/notes", "marker-from-notes-space"),
] {
let url = format!("{base}{prefix}");
// Must stay comfortably above the pool's LAUNCH_TIMEOUT (120s in
// server-runtime-chrome/src/pool.rs): the first space's eval pays for
// the browser launch plus a full client boot and sync.
let deadline = Instant::now() + Duration::from_secs(180);
#[allow(unused_assignments)]
let mut last = String::new();
loop {
let (code, stdout, stderr) = run_sb(
&[
"--url",
&url,
"--token",
&token,
"eval",
"space.readPage(\"Marker\")",
],
sb_config.path(),
);
if code == 0 && stdout.contains(expected) {
break;
}
last = format!("code={code} stdout={stdout:?} stderr={stderr:?}");
if Instant::now() >= deadline {
let log = server_proc.finish();
panic!(
"space at {url:?} never returned {expected:?}\nlast: {last}\n\
--- server log ---\n{log}"
);
}
std::thread::sleep(Duration::from_secs(1));
}
}
// One browser served both spaces.
let log = server_proc.finish();
let launches = log.matches("launching shared headless Chrome").count();
assert_eq!(
launches, 1,
"expected exactly one shared Chrome launch, saw {launches}\n--- server log ---\n{log}"
);
}
+35 -21
View File
@@ -10,7 +10,7 @@ use silverbullet_server::multi::access::SessionPolicy;
use silverbullet_server::multi::admin_api::{build_admin_api_router, AdminState};
use silverbullet_server::multi::dispatch::build_main_router;
use silverbullet_server::multi::instance::{
AssetFactories, InstanceAuth, InstanceDeps, RuntimeRequest,
AssetFactories, InstanceAuth, InstanceDeps, RuntimeFactory, RuntimeRequest,
};
use silverbullet_server::multi::manager::MultiManager;
use silverbullet_server::multi::space_index::{build_spaces_router, SpaceIndexState};
@@ -79,7 +79,7 @@ pub async fn build_multi_stack(config: &Config) -> Result<(axum::Router, String)
client_bundle: Box::new(|| Box::new(EmbeddedSpace::<ClientAssets>::new())),
base_fs: Box::new(|| Box::new(EmbeddedSpace::<BaseFsAssets>::new())),
},
runtime: Box::new(build_space_runtime),
runtime: space_runtime_factory(&root),
metrics: metrics.clone(),
auth: InstanceAuth::Accounts {
users: store.clone(),
@@ -185,27 +185,41 @@ pub async fn run_multi(config: Config) -> Result<(), String> {
.map_err(|e| format!("server error: {e}"))
}
/// Per-space headless-Chrome runtime factory (same construction as the
/// single-space `build_runtime`).
pub(crate) fn build_space_runtime(
req: &RuntimeRequest,
) -> Option<Box<dyn silverbullet_server::runtime::RuntimeBackend>> {
let chrome_cfg = silverbullet_server_runtime_chrome::ChromeConfig::from_env(
req.server_url.clone(),
req.headless_token.to_string(),
req.space_folder,
req.read_only,
)?;
let logs = silverbullet_server::runtime::LogBuffer::new();
match silverbullet_server_runtime_chrome::ChromeTransport::launch(chrome_cfg, logs.clone()) {
Ok(transport) => Some(Box::new(silverbullet_server::runtime::ClientRuntime::new(
transport, logs,
))),
Err(e) => {
tracing::warn!("runtime disabled for space: could not launch Chrome: {e}");
/// Build the runtime factory for a server rooted at `server_root`.
///
/// One `ChromePool` — one Chrome process — serves every space; each space gets
/// its own page, log buffer, and auth cookie. The pool is created eagerly but
/// launches nothing until some space's runtime API is first used.
pub(crate) fn space_runtime_factory(server_root: &std::path::Path) -> RuntimeFactory {
let pool = match silverbullet_server_runtime_chrome::ChromeConfig::from_env(server_root) {
Some(config) => match silverbullet_server_runtime_chrome::ChromePool::new(config) {
Ok(pool) => Some(pool),
Err(e) => {
tracing::warn!("runtime API disabled: could not create the Chrome pool: {e}");
None
}
},
None => {
tracing::info!("runtime API disabled (no Chrome found, or SB_RUNTIME_API=0)");
None
}
}
};
Box::new(move |req: &RuntimeRequest| {
let pool = pool.as_ref()?;
if req.read_only {
return None;
}
let page = silverbullet_server_runtime_chrome::SpacePage {
server_url: req.server_url.clone(),
headless_token: req.headless_token.to_string(),
cookie_name: silverbullet_server::auth::headless_cookie_name(req.space_id),
};
let logs = silverbullet_server::runtime::LogBuffer::new();
let transport = pool.transport_for(page, logs.clone());
Some(Box::new(silverbullet_server::runtime::ClientRuntime::new(
transport, logs,
)))
})
}
#[cfg(unix)]
+1 -1
View File
@@ -119,7 +119,7 @@ pub async fn run_single(config: Config) -> Result<(), String> {
client_bundle: Box::new(|| Box::new(EmbeddedSpace::<ClientAssets>::new())),
base_fs: Box::new(|| Box::new(EmbeddedSpace::<BaseFsAssets>::new())),
},
runtime: Box::new(crate::multi::build_space_runtime),
runtime: crate::multi::space_runtime_factory(&root),
metrics: metrics.clone(),
auth: InstanceAuth::Single(auth),
version: crate::VERSION.to_string(),
+37
View File
@@ -40,3 +40,40 @@ pub fn free_port() -> u16 {
}
panic!("no unused port found after 100 attempts");
}
/// Guard for the headless-Chrome runtime e2e tests: `true` when the server
/// they spawn will be able to find a browser, `false` when the test should skip.
///
/// The precedence deliberately mirrors `ChromeConfig::from_env` —
/// `SB_CHROME_PATH`, then `CHROMIUM_PATH`, then auto-detection — because that is
/// what the *spawned server* uses. A guard that consults only `find_chrome()`
/// answers a different question: it would skip on a machine where
/// `CHROMIUM_PATH` points at a perfectly good browser (Playwright installs into
/// `~/.cache/ms-playwright/…`, which `find_chrome` does not probe), so pointing
/// CI at Playwright's chromium would not actually stop these tests skipping.
///
/// And in CI a skip is fatal, not a courtesy: `release` and `docker` gate on
/// this job, so a run that skips its way to green would ship code that nothing
/// exercised. Locally, skipping is still the right behaviour — not every
/// developer has Chrome.
///
/// Not used by every test binary that includes this module, hence the `allow`.
#[allow(dead_code)]
pub fn chrome_available_or_skip(test_name: &str) -> bool {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
if env("SB_CHROME_PATH").is_some()
|| env("CHROMIUM_PATH").is_some()
|| silverbullet_server_runtime_chrome::find_chrome().is_some()
{
return true;
}
if std::env::var("CI").is_ok() {
panic!(
"{test_name}: no Chrome/Chromium found — SB_CHROME_PATH, CHROMIUM_PATH and \
auto-detection all came up empty. Refusing to skip in CI: this job gates the \
release and docker publishes, so a skipped run would ship untested code."
);
}
eprintln!("skipping {test_name}: no Chrome/Chromium found on this machine");
false
}
+5 -4
View File
@@ -3,7 +3,9 @@
//! Spawns the compiled `silverbullet` binary as a subprocess (so killing the
//! child cleanly tears down the embedded Chrome), boots it with the runtime
//! enabled, and drives `/.runtime/*` over HTTP. Gated on Chrome being available
//! so machines without Chrome skip it cleanly.
//! so machines without Chrome skip it cleanly — except under `CI`, where a
//! missing browser fails the test instead (see
//! `common::chrome_available_or_skip`).
use std::io::Read;
use std::process::{Child, Command, Stdio};
@@ -21,7 +23,7 @@ impl Drop for Server {
}
mod common;
use common::free_port;
use common::{chrome_available_or_skip, free_port};
/// Poll `cond` until it returns true or the deadline passes. On timeout, dump the
/// server's captured stdout/stderr and panic with `msg`.
@@ -58,8 +60,7 @@ fn dump_and_panic(server: &mut Server, msg: &str) -> ! {
#[test]
fn runtime_api_evaluates_lua_against_headless_chrome() {
if silverbullet_server_runtime_chrome::find_chrome().is_none() {
eprintln!("skipping runtime_e2e: no Chrome/Chromium found on this machine");
if !chrome_available_or_skip("runtime_e2e") {
return;
}
+1 -1
View File
@@ -4,7 +4,7 @@ partOf: "[[Architecture/Server]]"
consumes: "[[Architecture/Space Files]]"
references:
- server-runtime-chrome/src/supervisor.rs
- server-runtime-chrome/src/transport.rs
- server-runtime-chrome/src/pool.rs
- server-runtime-chrome/src/lib.rs
---
In order to implement the [[Runtime API]],
+11
View File
@@ -5,6 +5,17 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be
* Fix: the FreeBSD **server** binary is being built and released again
* Fix: [[Space Manager|multi-space]] mode silently ignored `SB_REMEMBER_ME_HOURS`, `SB_LOCKOUT_TIME`, and `SB_LOCKOUT_LIMIT`, hardcoding “remember me” sessions to 7 days and lockout to 10 attempts per minute. All three now apply there too — server-wide, like the session itself — matching what [[Install/Configuration]] documents.
* Multi-space servers now share a single headless Chrome across all spaces
instead of launching one browser per space. Startup stays lazy: no browser
until the Runtime API is first used, and no tab for a space nobody queries.
* The headless Chrome profile now lives at `<server root>/.chrome-data`.
Single-space servers are unaffected (the root *is* the space folder); on a
multi-space server the old per-space `.chrome-data` directories are no longer
used and can be deleted.
* Fix: the Runtime API failed to start when authentication was enabled — the
headless page authorized only its first request and was then redirected to
`/.auth`, leaving `/.runtime/*` answering `bridge_unavailable`. It now
authenticates with a session cookie for the whole session. ([#2072](https://github.com/silverbulletmd/silverbullet/pull/2072))
## 2.10.0
* [[Space Manager]]: multi-space hosting with multiple accounts is here. A fresh install pointed at an empty folder opens a browser-based first-run **setup wizard** that creates an admin account and your first space, then serves it in place with no restart. One server can host any number of [[Space|spaces]], each bound to a URL prefix or hostname.
+1 -1
View File
@@ -44,7 +44,7 @@ To force the classic single-space server on an empty folder, pass `--single` (or
* `SB_RUNTIME_API`: The [[Runtime API]] is enabled automatically when Chrome/Chromium is detected on the system. Set to `0` to explicitly disable. Not available in read-only mode.
* `SB_CHROME_PATH`: Optional explicit path to the Chrome/Chromium binary. Falls back to the `CHROMIUM_PATH` environment variable (pre-set in the `-runtime-api` Docker image), then auto-detection.
* `SB_CHROME_SHOW`: Set to any non-empty value to run Chrome with a visible window instead of headless (useful for debugging).
* `SB_CHROME_DATA_DIR`: Path to persist the Chrome user profile between restarts. When not set, defaults to `.chrome-data` inside the space folder.
* `SB_CHROME_DATA_DIR`: Path to persist the Chrome user profile between restarts. When not set, defaults to `.chrome-data` inside the server root — one profile for the whole server, since one browser serves every space. For a single-space server this is unchanged, because there the root *is* the space folder.
* `SB_CHROME_LOG_CONSOLE`: Forward the headless Chrome page’s `console.*` output to the server log (so you can see what the runtime is doing). Enabled by default; set to `0` to disable. The same log is also available via `/.runtime/logs` (e.g. `sb logs`).
# Security
+2 -2
View File
@@ -133,7 +133,7 @@ Status codes used across the Runtime API:
# How it works
As documented in [[Architecture]], the vast majority of SilverBullet’s power is implemented in the client. However, there are use cases for programmatically accessing your space with all of SilverBullet (client’s) power.
When the Runtime API is enabled, the server launches a headless (invisible by default) Chrome process upon the first request to an `/.remote` endpoint. This browser loads the full SilverBullet client, exactly like a regular browser tab, but without a visible window (with some memory optimizations). The client boots normally: it loads all plugs, Lua code and navigates to the index page.
When the Runtime API is enabled, the server launches a single, server-wide headless (invisible by default) Chrome process upon the first request to an `/.remote` endpoint. Each space with the runtime API enabled gets its own page (tab) in that shared browser, opened on that space’s first request. A page loads the full SilverBullet client, exactly like a regular browser tab, but without a visible window (with some memory optimizations). The client boots normally: it loads all plugs, Lua code and navigates to the index page.
Once ready, the server communicates with the browser directly via Chrome DevTools Protocol (CDP). Because Lua code runs inside a real SilverBullet client, it has access to the full API surface — `editor.*`, `space.*`, queries, and everything else available to in-page scripts and widgets. The results reflect live client state.
@@ -141,4 +141,4 @@ Once ready, the server communicates with the browser directly via Chrome DevTool
Set `SB_CHROME_SHOW=1` to run Chrome with a visible window — useful for watching what the headless client is doing. Set `SB_CHROME_DATA_DIR` to a path to persist the Chrome profile between restarts (avoids re-indexing on each restart).
## Resource usage
Headless Chrome spawns several processes (browser, network, storage, and renderer). With the full SilverBullet client loaded and indexed, expect roughly **150–200 MB** of total RSS across all Chrome processes. The SilverBullet server itself adds ~30 MB on top of this.
Headless Chrome spawns several processes (browser, network, storage, and renderer). With the full SilverBullet client loaded and indexed in a single space, expect roughly **150–200 MB** of total RSS across all Chrome processes. Because the browser is shared, additional spaces cost a renderer each rather than a whole browser. The SilverBullet server itself adds ~30 MB on top of this.
+1 -1
View File
@@ -100,4 +100,4 @@ Single-space mode is the “classic” SilverBullet server: one folder, one spac
* Spaces share one OS process and user. This mode is built for a household or team of trusted spaces, not hostile multi-tenancy.
* Authentication is shared across the server, while authorization remains per space. Password changes and account deletion revoke that user's sessions immediately; membership and admin-role changes also take effect on the next request.
* Because the session is server-wide, so is its policy: `SB_REMEMBER_ME_HOURS`, `SB_LOCKOUT_TIME`, and `SB_LOCKOUT_LIMIT` (see [[Install/Configuration#Authentication]]) apply to every space and to the space list itself, and are set as environment variables rather than per space in `spaces.json`.
* The runtime API (`runtimeApi`) launches one headless Chrome per enabled space, lazily; it is off by default.
* The runtime API (`runtimeApi`) uses a single, server-wide headless Chrome with one page (tab) per enabled space. Both levels are lazy: the browser only starts on the first runtime request from any space, and a space only gets a tab on its own first request. It is off by default.
+132 -116
View File
@@ -1,43 +1,28 @@
use std::path::Path;
/// Resolved configuration for launching the headless browser.
/// Configuration for the single, server-wide headless browser. Resolved once at
/// startup and shared by every space's page.
#[derive(Debug, Clone)]
pub struct ChromeConfig {
pub chrome_path: String,
pub server_url: String,
/// Headless auth token, always appended to the page URL as `&token=…`. When
/// authentication is disabled the open server ignores it; when enabled, the
/// headless-token authorizer accepts it so the headless page is authorized.
pub headless_token: String,
pub user_data_dir: String,
pub show: bool,
/// Forward the headless page's captured `console.*` output to the server's
/// `tracing` log (under the `runtime_console` target) in addition to the
/// `/.runtime/logs` buffer. On by default; disabled with
/// `SB_CHROME_LOG_CONSOLE=0`.
pub log_console: bool,
}
impl ChromeConfig {
/// Build from the process environment. `server_url` is the loopback base
/// URL the headless page should load (`http://127.0.0.1:<port><prefix>`);
/// `headless_token` is always generated by the caller; it's only consulted
/// by the auth layer when authentication is enabled.
pub fn from_env(
server_url: String,
headless_token: String,
space_folder: &str,
read_only: bool,
) -> Option<Self> {
/// Build from the process environment. `server_root` is the server's root
/// directory, used for the default profile location. Returns `None` when
/// the runtime API is disabled or no Chrome can be found.
pub fn from_env(server_root: &Path) -> Option<Self> {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let runtime_api_enabled =
!matches!(env("SB_RUNTIME_API").as_deref(), Some("0") | Some("false"));
Self::resolve(
env("SB_CHROME_PATH"),
env("CHROMIUM_PATH"),
server_url,
headless_token,
space_folder.to_string(),
env("SB_CHROME_DATA_DIR"),
server_root,
env("SB_CHROME_SHOW").is_some(),
// On by default; disabled only with SB_CHROME_LOG_CONSOLE=0/false
// (matches the SB_RUNTIME_API opt-out convention).
@@ -45,42 +30,35 @@ impl ChromeConfig {
env("SB_CHROME_LOG_CONSOLE").as_deref(),
Some("0") | Some("false")
),
read_only,
runtime_api_enabled,
)
}
/// Pure resolution (unit-tested). Returns `None` when the runtime API is
/// disabled, the space is read-only, or no Chrome can be found.
#[allow(clippy::too_many_arguments)]
/// Pure resolution (unit-tested). Seven parameters is exactly clippy's
/// `too_many_arguments` threshold, so no `allow` is needed — do not add one.
pub fn resolve(
sb_chrome_path: Option<String>,
chromium_path: Option<String>,
server_url: String,
headless_token: String,
space_folder: String,
chrome_data_dir: Option<String>,
server_root: &Path,
show: bool,
log_console: bool,
read_only: bool,
runtime_api_enabled: bool,
) -> Option<Self> {
if !runtime_api_enabled || read_only {
if !runtime_api_enabled {
return None;
}
let chrome_path = sb_chrome_path.or(chromium_path).or_else(find_chrome)?;
let user_data_dir = std::env::var("SB_CHROME_DATA_DIR")
.ok()
let user_data_dir = chrome_data_dir
.filter(|v| !v.is_empty())
.unwrap_or_else(|| {
Path::new(&space_folder)
server_root
.join(".chrome-data")
.to_string_lossy()
.into_owned()
});
Some(Self {
chrome_path,
server_url,
headless_token,
user_data_dir,
show,
log_console,
@@ -88,6 +66,48 @@ impl ChromeConfig {
}
}
/// Per-space page configuration: where this space's client lives and how its
/// headless page authenticates to it.
#[derive(Debug, Clone)]
pub struct SpacePage {
/// Loopback base URL for this space (`http://127.0.0.1:<port><prefix>`).
pub server_url: String,
/// Headless auth token, seeded as an HTTP-only session cookie before the
/// page navigates. Never appears in the URL.
pub headless_token: String,
/// This space's cookie name (`silverbullet_headless_<space_id>`).
pub cookie_name: String,
}
impl SpacePage {
/// The headless page URL: the space base URL with a trailing slash and
/// `?headless=1`. Authentication rides in the cookie, not the query string.
pub fn page_url(&self) -> String {
let base = self.server_url.trim_end_matches('/');
format!("{base}/?headless=1")
}
/// `Path` attribute for this space's auth cookie: the space prefix, or `/`
/// for a root-bound space.
pub fn cookie_path(&self) -> String {
let path = self
.server_url
.split_once("://")
.and_then(|(_, authority_and_path)| {
authority_and_path
.find('/')
.map(|index| &authority_and_path[index..])
})
.unwrap_or("/");
let path = path.trim_end_matches('/');
if path.is_empty() {
"/".to_string()
} else {
path.to_string()
}
}
}
/// Find a Chrome/Chromium executable from platform-specific candidates.
pub fn find_chrome() -> Option<String> {
if cfg!(target_os = "macos") {
@@ -167,100 +187,96 @@ fn which_on_path(name: &str) -> Option<String> {
mod tests {
use super::*;
#[test]
fn explicit_path_wins_over_discovery() {
let cfg = ChromeConfig::resolve(
Some("/custom/chrome".into()),
fn resolve(root: &str, chrome_data_dir: Option<&str>) -> Option<ChromeConfig> {
ChromeConfig::resolve(
Some("/bin/chrome".into()),
None,
"http://127.0.0.1:3000".into(),
String::new(),
"/tmp/space".into(),
false,
false,
chrome_data_dir.map(str::to_string),
Path::new(root),
false,
true,
);
assert_eq!(cfg.unwrap().chrome_path, "/custom/chrome");
}
#[test]
fn chromium_path_is_fallback_for_explicit() {
let cfg = ChromeConfig::resolve(
None,
Some("/usr/bin/chromium".into()),
"u".into(),
String::new(),
"/s".into(),
false,
false,
false,
true,
);
assert_eq!(cfg.unwrap().chrome_path, "/usr/bin/chromium");
}
#[test]
fn disabled_when_runtime_api_off_or_read_only() {
assert!(ChromeConfig::resolve(
Some("/x".into()),
None,
"u".into(),
String::new(),
"/s".into(),
false,
false,
false,
false
)
.is_none());
assert!(ChromeConfig::resolve(
Some("/x".into()),
}
#[test]
fn profile_defaults_to_the_server_root() {
let cfg = resolve("/srv/sb", None).expect("resolves");
assert_eq!(cfg.chrome_path, "/bin/chrome");
assert_eq!(cfg.user_data_dir, "/srv/sb/.chrome-data");
}
#[test]
fn explicit_data_dir_wins() {
let cfg = resolve("/srv/sb", Some("/var/cache/sb-chrome")).expect("resolves");
assert_eq!(cfg.user_data_dir, "/var/cache/sb-chrome");
}
#[test]
fn empty_data_dir_is_ignored() {
let cfg = ChromeConfig::resolve(
Some("/bin/chrome".into()),
None,
"u".into(),
String::new(),
"/s".into(),
false,
Some(String::new()),
Path::new("/srv/sb"),
false,
true,
true
true,
)
.expect("resolves");
assert_eq!(cfg.user_data_dir, "/srv/sb/.chrome-data");
}
#[test]
fn chromium_path_is_the_fallback() {
let cfg = ChromeConfig::resolve(
None,
Some("/bin/chromium".into()),
None,
Path::new("/srv/sb"),
false,
true,
true,
)
.expect("resolves");
assert_eq!(cfg.chrome_path, "/bin/chromium");
}
#[test]
fn disabled_runtime_api_resolves_to_nothing() {
assert!(ChromeConfig::resolve(
Some("/bin/chrome".into()),
None,
None,
Path::new("/srv/sb"),
false,
true,
false,
)
.is_none());
}
#[test]
fn data_dir_defaults_under_space() {
let cfg = ChromeConfig::resolve(
Some("/x".into()),
None,
"u".into(),
String::new(),
"/space".into(),
false,
false,
false,
true,
)
.unwrap();
assert!(cfg.user_data_dir.ends_with(".chrome-data"));
assert!(cfg.user_data_dir.contains("space"));
fn page_url_carries_no_token() {
let page = SpacePage {
server_url: "http://127.0.0.1:3000/notes".into(),
headless_token: "secret".into(),
cookie_name: "silverbullet_headless_a".into(),
};
assert_eq!(page.page_url(), "http://127.0.0.1:3000/notes/?headless=1");
assert!(!page.page_url().contains("secret"));
}
#[test]
fn token_show_and_log_console_flow_through() {
let cfg = ChromeConfig::resolve(
Some("/x".into()),
None,
"u".into(),
"tok123".into(),
"/s".into(),
true,
true,
false,
true,
)
.unwrap();
assert_eq!(cfg.headless_token, "tok123");
assert!(cfg.show);
assert!(cfg.log_console);
fn cookie_path_is_the_space_prefix() {
let page = |url: &str| SpacePage {
server_url: url.into(),
headless_token: "secret".into(),
cookie_name: "silverbullet_headless_a".into(),
};
assert_eq!(page("http://127.0.0.1:3000/notes").cookie_path(), "/notes");
assert_eq!(page("http://127.0.0.1:3000").cookie_path(), "/");
assert_eq!(page("http://127.0.0.1:3000/").cookie_path(), "/");
assert_eq!(page("http://127.0.0.1:3000/a/b").cookie_path(), "/a/b");
}
}
+3 -3
View File
@@ -3,8 +3,8 @@
//! server can evaluate Space Lua and answer the objects API.
mod config;
mod pool;
mod supervisor;
mod transport;
pub use config::{find_chrome, ChromeConfig};
pub use transport::ChromeTransport;
pub use config::{find_chrome, ChromeConfig, SpacePage};
pub use pool::{ChromePool, SharedChromeTransport};
+478
View File
@@ -0,0 +1,478 @@
//! The shared headless-Chrome pool: one browser process for the whole server,
//! one page per space.
//!
//! Launch is LAZY at two levels. The browser does not start until the first
//! runtime request from *any* space, and a space's page is not created until
//! that space's own first request — so Chrome never runs while the runtime API
//! is unused, and a space nobody queries never costs a tab.
//!
//! `eval_js`/`wait_ready` are synchronous and block the calling thread (the
//! server invokes them via `spawn_blocking`) they run their async work on the
//! pool's owned runtime.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use chromiumoxide::browser::Browser;
use chromiumoxide::page::Page;
use serde_json::Value;
use silverbullet_server::runtime::{ClientTransport, LogBuffer, RuntimeError};
use tokio::runtime::Runtime;
use tokio::sync::{Mutex, Notify};
use crate::config::{ChromeConfig, SpacePage};
use crate::supervisor::{eval_on_page, launch_browser, supervise_space};
/// One registered space: its live page, readiness flag, and supervisor handle.
struct Registration {
live: Arc<Mutex<Option<Page>>>,
supervisor: tokio::task::JoinHandle<()>,
}
/// The pool's browser slot: the live browser tagged with the generation it was
/// launched as, or empty when there is none.
type BrowserSlot<B> = Arc<Mutex<Option<(u64, Arc<B>)>>>;
/// The shared headless-Chrome pool.
pub struct ChromePool<B = Browser> {
rt: Option<Runtime>,
config: ChromeConfig,
browser: BrowserSlot<B>,
launch_lock: Arc<Mutex<()>>,
spaces: Arc<StdMutex<HashMap<u64, Registration>>>,
next_id: AtomicU64,
next_generation: AtomicU64,
}
/// Clear `slot` when — and only when — it still holds `generation`. Returns
/// whether it cleared anything.
///
/// This is the guard that stops a browser restart from ping-ponging: a
/// supervisor that noticed generation *G* had died may arrive long after
/// somebody else already launched *G+1* (a page launch takes seconds), and it
/// must not take the live browser down with it.
fn clear_if_generation<T>(slot: &mut Option<(u64, T)>, generation: u64) -> bool {
match slot {
Some((current, _)) if *current == generation => {
*slot = None;
true
}
_ => false,
}
}
impl<B> ChromePool<B> {
/// Construct the pool. Generic over the browser handle so tests can build a
/// pool whose slot they are able to populate; `ChromePool::new` is the
/// production entry point.
fn build(config: ChromeConfig) -> Result<Arc<Self>, RuntimeError> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| RuntimeError::Transport(format!("tokio runtime: {e}")))?;
Ok(Arc::new(Self {
rt: Some(rt),
config,
browser: Arc::new(Mutex::new(None)),
launch_lock: Arc::new(Mutex::new(())),
spaces: Arc::new(StdMutex::new(HashMap::new())),
next_id: AtomicU64::new(0),
next_generation: AtomicU64::new(0),
}))
}
/// The owned runtime. Always present until `Drop`; the `Option` exists only
/// so `Drop` can take it.
fn rt(&self) -> &Runtime {
self.rt.as_ref().expect("runtime present until drop")
}
pub fn config(&self) -> &ChromeConfig {
&self.config
}
/// How many spaces currently hold a transport. Registration is per
/// *transport*, not per space id: a space being rebuilt briefly has two.
pub fn registered_spaces(&self) -> usize {
self.spaces.lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Retire the browser of `generation` so the next `ensure_browser` launches
/// a fresh one. Called by a supervisor that saw *this* browser fail a
/// liveness probe.
pub(crate) async fn discard_browser(&self, generation: u64) {
// Taken before the slot lock so this cannot interleave with
// `ensure_browser`'s launch-then-store.
let _guard = self.launch_lock.lock().await;
let mut slot = self.browser.lock().await;
if clear_if_generation(&mut slot, generation) {
tracing::warn!("shared headless Chrome (generation {generation}) died; retiring it");
}
}
/// Whether a browser process is currently held. Exists to pin the laziness
/// guarantee in tests — nothing must launch Chrome before the first runtime
/// request.
#[cfg(test)]
pub(crate) async fn browser_is_launched(&self) -> bool {
self.browser.lock().await.is_some()
}
/// Install `browser` in the slot at `generation`, as a successful
/// `ensure_browser` would. Tests only: it is the sole way to exercise
/// `discard_browser` without launching real Chrome.
#[cfg(test)]
async fn seed_browser(&self, generation: u64, browser: B) {
*self.browser.lock().await = Some((generation, Arc::new(browser)));
}
}
impl ChromePool {
/// Create the pool. Returns an error only if the tokio runtime can't be
/// built.
pub fn new(config: ChromeConfig) -> Result<Arc<Self>, RuntimeError> {
Self::build(config)
}
/// Register a space and return its transport. Spawns the space's supervisor
/// parked on its trigger; no browser or page work happens until the first
/// runtime request.
pub fn transport_for(
self: &Arc<Self>,
page: SpacePage,
logs: LogBuffer,
) -> SharedChromeTransport {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let live: Arc<Mutex<Option<Page>>> = Arc::new(Mutex::new(None));
let ready = Arc::new(AtomicBool::new(false));
let trigger = Arc::new(Notify::new());
let supervisor = self.rt().spawn(supervise_space(
self.clone(),
page.clone(),
live.clone(),
ready.clone(),
logs,
trigger.clone(),
));
self.spaces
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
id,
Registration {
live: live.clone(),
supervisor,
},
);
SharedChromeTransport {
pool: self.clone(),
id,
live,
ready,
trigger,
}
}
/// The live browser and its generation, launching it if there isn't one.
/// Concurrent callers serialize on `launch_lock`; the second one finds the
/// slot already filled and returns it.
///
/// The returned `Arc` is a *transient* clone: use it for one `new_page` and
/// drop it. Holding it would keep a dead Chrome's process alive across a
/// relaunch. The generation goes back to `discard_browser` if this browser
/// turns out to be dead.
pub(crate) async fn ensure_browser(&self) -> Result<(u64, Arc<Browser>), String> {
/// Cap on a single `Browser::launch`. It is awaited while holding
/// `launch_lock`, so a Chrome that hangs on startup would otherwise
/// block *every* space's supervisor forever.
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(120);
if let Some((generation, b)) = self.browser.lock().await.as_ref() {
return Ok((*generation, b.clone()));
}
let _guard = self.launch_lock.lock().await;
if let Some((generation, b)) = self.browser.lock().await.as_ref() {
return Ok((*generation, b.clone()));
}
tracing::info!(
"runtime API used; launching shared headless Chrome ({})",
self.config.chrome_path
);
let browser = match tokio::time::timeout(LAUNCH_TIMEOUT, launch_browser(&self.config)).await
{
Err(_) => {
return Err(format!(
"browser launch timed out after {}s",
LAUNCH_TIMEOUT.as_secs()
))
}
Ok(result) => Arc::new(result?),
};
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
*self.browser.lock().await = Some((generation, browser.clone()));
Ok((generation, browser))
}
}
impl<B> Drop for ChromePool<B> {
fn drop(&mut self) {
// Abort every supervisor first so none of them races the teardown.
for (_, reg) in self
.spaces
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain()
{
reg.supervisor.abort();
drop(reg.live);
}
if let Some(rt) = self.rt.take() {
// `shutdown_background` rather than a blocking shutdown: the pool is
// dropped from inside the server's own async context on Ctrl-C, and
// blocking there panics.
rt.shutdown_background();
}
}
}
/// One space's view of the shared browser: its own page, readiness flag, and
/// lazy-launch trigger.
pub struct SharedChromeTransport {
pool: Arc<ChromePool>,
id: u64,
live: Arc<Mutex<Option<Page>>>,
ready: Arc<AtomicBool>,
trigger: Arc<Notify>,
}
impl ClientTransport for SharedChromeTransport {
fn eval_js(&self, js: &str, timeout: Duration) -> Result<Value, RuntimeError> {
// Any runtime use wakes this space's supervisor, which brings the shared
// browser up if needed and then this space's page.
self.trigger.notify_one();
let live = self.live.clone();
let js = js.to_string();
self.pool.rt().block_on(async move {
// The timeout covers *acquiring* `live` as well as the eval itself.
// The eval alone was never unbounded — chromiumoxide gives every
// command a ~30s `REQUEST_TIMEOUT` of its own — but the lock wait in
// front of it had no bound at all, and it is the part that stacks:
// this call occupies a thread on the server's shared
// `spawn_blocking` pool, and the supervisor's liveness probe takes
// the same lock. Wrapping both means a caller's own timeout is the
// real bound, instead of an implicit one from a dependency plus an
// unbounded queue behind it.
let attempt = async {
let guard = live.lock().await;
let page = guard.as_ref().ok_or(RuntimeError::NotReady)?;
eval_on_page(page, &js).await
};
match tokio::time::timeout(timeout, attempt).await {
Err(_) => Err(RuntimeError::Timeout),
Ok(r) => r,
}
})
}
fn wait_ready(&self, timeout: Duration) -> Result<(), RuntimeError> {
self.trigger.notify_one();
if self.ready.load(Ordering::Relaxed) {
return Ok(());
}
let ready = self.ready.clone();
self.pool.rt().block_on(async move {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if ready.load(Ordering::Relaxed) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(RuntimeError::NotReady);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
}
fn is_ready(&self) -> bool {
self.ready.load(Ordering::Relaxed)
}
fn ensure_started(&self) {
// Same lazy-launch nudge as eval/wait: a log read alone is enough to
// bring this space's page up.
self.trigger.notify_one();
}
}
impl Drop for SharedChromeTransport {
fn drop(&mut self) {
// A space instance is rebuilt on *every* admin API space write, so
// without this each write would leak a tab in the shared browser.
let reg = self
.pool
.spaces
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.id);
let Some(reg) = reg else { return };
let Registration { live, supervisor } = reg;
self.pool.rt().spawn(async move {
// Abort *and wait* before touching `live`. `JoinHandle::abort` is
// not synchronous: cancelling the supervisor while it is inside
// `launch_page` means its page is a task local, in neither `live`
// nor anywhere else, until its `PageCloseGuard` drops. Aborting from
// `Drop` and closing here immediately could observe `live == None`,
// finish, and let the supervisor then publish a page nobody will
// ever look at again. Awaiting the handle guarantees the task (and
// its guard) is fully torn down first.
supervisor.abort();
let _ = supervisor.await;
// Two statements, so the `MutexGuard` is dropped at the `;` rather
// than held across the `close` await — matching `supervise_space`'s
// restart path. Nothing contends for `live` here, but the two sites
// should read alike.
let stale = live.lock().await.take();
if let Some(page) = stale {
let _ = page.close().await;
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> ChromeConfig {
ChromeConfig {
// Never launched: registration and teardown are lazy, so no real
// browser is needed to exercise the pool's bookkeeping.
chrome_path: "/nonexistent/chrome".into(),
user_data_dir: "/tmp/sb-pool-test".into(),
show: false,
log_console: false,
}
}
fn page(name: &str, url: &str) -> SpacePage {
SpacePage {
server_url: url.into(),
headless_token: "secret".into(),
cookie_name: format!("silverbullet_headless_{name}"),
}
}
#[test]
fn registers_and_deregisters_each_space() {
let pool = ChromePool::new(config()).unwrap();
assert_eq!(pool.registered_spaces(), 0);
let a = pool.transport_for(page("a", "http://127.0.0.1:3000"), LogBuffer::new());
let b = pool.transport_for(page("b", "http://127.0.0.1:3000/notes"), LogBuffer::new());
assert_eq!(pool.registered_spaces(), 2);
drop(a);
assert_eq!(pool.registered_spaces(), 1);
drop(b);
assert_eq!(pool.registered_spaces(), 0);
}
#[test]
fn rebuilding_a_space_does_not_deregister_its_replacement() {
let pool = ChromePool::new(config()).unwrap();
let old = pool.transport_for(page("a", "http://127.0.0.1:3000"), LogBuffer::new());
let new = pool.transport_for(page("a", "http://127.0.0.1:3000"), LogBuffer::new());
assert_eq!(pool.registered_spaces(), 2);
drop(old);
assert_eq!(pool.registered_spaces(), 1);
drop(new);
assert_eq!(pool.registered_spaces(), 0);
}
#[tokio::test]
async fn no_browser_is_launched_before_any_runtime_request() {
let pool = ChromePool::new(config()).unwrap();
assert!(!pool.browser_is_launched().await);
let t = pool.transport_for(page("a", "http://127.0.0.1:3000"), LogBuffer::new());
// Give the supervisor task a chance to run before checking.
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!pool.browser_is_launched().await);
assert!(!t.is_ready());
}
#[test]
fn a_stale_discard_leaves_a_newer_browser_alone() {
let mut slot = Some((1u64, "browser-1"));
assert!(!clear_if_generation(&mut slot, 0));
assert_eq!(slot, Some((1, "browser-1")));
}
#[test]
fn discarding_the_current_generation_clears_the_slot() {
let mut slot = Some((1u64, "browser-1"));
assert!(clear_if_generation(&mut slot, 1));
assert_eq!(slot, None);
}
#[tokio::test]
async fn discard_browser_retires_only_the_generation_it_was_given() {
let pool = ChromePool::<&'static str>::build(config()).unwrap();
pool.seed_browser(1, "browser-1").await;
// A supervisor that watched generation 0 die, arriving after somebody
// else already launched generation 1, must leave it alone.
pool.discard_browser(0).await;
assert!(
pool.browser_is_launched().await,
"a stale generation must not retire the live browser"
);
// The generation that actually failed is retired, so the next
// `ensure_browser` launches a fresh one.
pool.discard_browser(1).await;
assert!(
!pool.browser_is_launched().await,
"the current generation must clear the slot"
);
}
#[test]
fn discarding_an_empty_slot_is_a_no_op() {
let mut slot: Option<(u64, &str)> = None;
assert!(!clear_if_generation(&mut slot, 0));
assert_eq!(slot, None);
}
#[test]
fn generations_are_monotonic() {
let pool = ChromePool::new(config()).unwrap();
let first = pool.next_generation.fetch_add(1, Ordering::Relaxed);
let second = pool.next_generation.fetch_add(1, Ordering::Relaxed);
assert_eq!((first, second), (0, 1));
}
#[tokio::test]
async fn a_failed_launch_leaves_the_slot_empty() {
let pool = ChromePool::new(config()).unwrap();
// `/nonexistent/chrome` cannot be spawned, so this fails fast.
assert!(pool.ensure_browser().await.is_err());
assert!(!pool.browser_is_launched().await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_the_pool_inside_an_async_context_does_not_panic() {
let pool = ChromePool::new(config()).unwrap();
let t = pool.transport_for(page("a", "http://127.0.0.1:3000"), LogBuffer::new());
drop(t);
drop(pool); // must not panic
}
}
+391 -81
View File
@@ -1,17 +1,19 @@
//! Browser supervisor: owns the live `Browser` + `Page`, captures console
//! output into the shared log buffer, waits for the client runtime to signal
//! Browser launch and per-space page supervision: brings up the shared
//! `Browser` for the pool, opens a space's `Page` in it, captures console output
//! into that space's log buffer, waits for the client runtime to signal
//! readiness, and restarts (with exponential backoff) whenever the page dies.
//!
//! The supervisor runs as a single long-lived task on the transport's tokio
//! runtime. It is deliberately tolerant of a not-yet-listening server: the
//! transport is constructed *before* the server binds its port, so the first
//! navigation may fail and is simply retried with backoff.
//! One supervisor task per space runs on the pool's tokio runtime. It is
//! deliberately tolerant of a not-yet-listening server: transports are
//! constructed *before* the server binds its port, so the first navigation may
//! fail and is simply retried with backoff.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::network::{CookieParam, CookieSameSite};
use chromiumoxide::cdp::js_protocol::runtime::{
ConsoleApiCalledType, EvaluateParams, EventConsoleApiCalled,
};
@@ -22,17 +24,7 @@ use serde_json::Value;
use silverbullet_server::runtime::{LogBuffer, LogEntry, RuntimeError};
use tokio::sync::{Mutex, Notify};
use crate::config::ChromeConfig;
/// The live browser and its single page, kept together so dropping the pair
/// (on restart) tears down the old Chrome process cleanly.
pub struct Live {
/// Held purely to keep the Chrome process alive; dropping `Live` (on
/// restart) drops this and tears Chrome down. Never read directly.
#[allow(dead_code)]
pub browser: Browser,
pub page: Page,
}
use crate::config::{ChromeConfig, SpacePage};
/// Evaluate a raw JS expression in the page with await-promise +
/// return-by-value semantics, returning its JSON value (`Null` when the
@@ -103,6 +95,18 @@ async fn eval_sync(page: &Page, js: &str) -> Result<Value, RuntimeError> {
Ok(result.value().cloned().unwrap_or(Value::Null))
}
/// Bound on the CDP round trips this module makes against a page it has reason
/// to distrust: the liveness probe, and every `close`.
///
/// Nothing here was ever *unbounded* — chromiumoxide gives every command its own
/// `REQUEST_TIMEOUT` (~30s) and resolves it to `CdpError::Timeout` — but 30s is
/// the wrong bound for these call sites, and inheriting it from a dependency's
/// internals leaves the policy invisible. A liveness probe evaluates the literal
/// `1` and a close is a single command; five seconds is already enormously
/// generous for either, and the difference between 5s and 30s is paid by the
/// server (see `page_is_alive`) or by a space's restart latency.
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Current wall-clock time in milliseconds since the Unix epoch.
fn now_millis() -> i64 {
SystemTime::now()
@@ -111,30 +115,20 @@ fn now_millis() -> i64 {
.unwrap_or(0)
}
/// Build the headless page URL from the configured server URL, trimming a
/// trailing slash and appending `?headless=1&token=…`. The token is always
/// present; an open (no-auth) server simply ignores it.
fn page_url(config: &ChromeConfig) -> String {
let base = config.server_url.trim_end_matches('/');
format!("{base}/?headless=1&token={}", config.headless_token)
/// The space's headless auth cookie: HTTP-only, `SameSite=Strict`, scoped by
/// `Path` to the space prefix, with no expiry (a session cookie).
pub(crate) fn headless_cookie(page: &SpacePage) -> Result<CookieParam, String> {
CookieParam::builder()
.name(&page.cookie_name)
.value(&page.headless_token)
.url(&page.server_url)
.path(page.cookie_path())
.http_only(true)
.same_site(CookieSameSite::Strict)
.build()
}
/// Launch a fresh browser + page, attach console capture, wait for the client
/// runtime to become ready, then publish the live pair into `live_slot` and
/// set `ready = true`. Returns `Err(msg)` on any failure (the caller backs off
/// and retries).
async fn launch_once(
config: &ChromeConfig,
live_slot: &Arc<Mutex<Option<Live>>>,
ready: &Arc<AtomicBool>,
logs: &LogBuffer,
) -> Result<(), String> {
// chromiumoxide's `.args()` expects BARE flag keys (no leading `--`) — it
// prepends the dashes itself when building the command line, so a value like
// `"--no-sandbox"` is mangled to `----no-sandbox` and silently ignored. Use
// the dedicated `.no_sandbox()` method (it emits the correct
// `--no-sandbox` + `--disable-setuid-sandbox`, required when running as root,
// e.g. inside a container) and pass the remaining flags as bare keys.
pub(crate) async fn launch_browser(config: &ChromeConfig) -> Result<Browser, String> {
let mut builder = BrowserConfig::builder()
.chrome_executable(&config.chrome_path)
.user_data_dir(&config.user_data_dir)
@@ -164,18 +158,127 @@ async fn launch_once(
}
});
let page = browser
.new_page(page_url(config).as_str())
.await
.map_err(|e| format!("new page: {e}"))?;
Ok(browser)
}
attach_console_capture(&page, logs, config.log_console)
/// A page handle whose tab must be closed explicitly rather than dropped.
///
/// Abstracted behind a trait for exactly one reason: `PageCloseGuard`'s
/// behaviour under task cancellation is the thing this module most needs to
/// test, and `chromiumoxide::Page` cannot be constructed without a live Chrome.
/// Production only ever instantiates it at `P = Page`.
///
/// Declared with an explicit `-> impl Future + Send` rather than `async fn`: the
/// close is handed to `tokio::spawn`, which needs the `Send` bound stated on the
/// trait itself.
trait ClosablePage: Send + 'static {
fn close_page(self) -> impl std::future::Future<Output = ()> + Send;
}
impl ClosablePage for Page {
async fn close_page(self) {
// Explicitly bounded, rather than leaning on chromiumoxide's ~30s
// request timeout: this frequently runs on a page already declared dead.
let _ = tokio::time::timeout(PROBE_TIMEOUT, self.close()).await;
}
}
/// Owns a freshly opened page and **closes** it on drop, unless disarmed.
///
/// chromiumoxide implements `Drop` for `Browser` but *not* for `Page` —
/// `Page::close` is an explicit `async fn` — so a dropped `Page` leaves its tab
/// and renderer process running inside the browser every other space depends
/// on. This used to be impossible: each attempt owned its own `Browser`, so a
/// failed attempt took the whole process tree with it. Sharing the browser
/// removed that implicit cleanup, and this guard replaces it.
struct PageCloseGuard<P: ClosablePage> {
page: Option<P>,
}
impl<P: ClosablePage> PageCloseGuard<P> {
fn new(page: P) -> Self {
Self { page: Some(page) }
}
/// The guarded page. Borrowed, never moved out: ownership stays with the
/// guard so cancellation at *any* await point still reaches `Drop`.
fn page(&self) -> &P {
self.page.as_ref().expect("armed until disarmed")
}
/// Give up responsibility for the page. Only correct once the page is
/// published somewhere that will close it — i.e. `live`.
fn disarm(&mut self) {
self.page = None;
}
}
impl<P: ClosablePage> Drop for PageCloseGuard<P> {
fn drop(&mut self) {
if let Some(page) = self.page.take() {
// Fire-and-forget: `Drop` cannot await. See the type-level comment
// for why a runtime is guaranteed to be present here.
tokio::spawn(page.close_page());
}
}
}
/// Open this space's page in the shared browser: blank page, auth cookie,
/// console capture, navigation, then wait for the client runtime to report
/// ready. Publishes into `live` and sets `ready` on success.
///
/// The tab is owned by a `PageCloseGuard` for the whole of this function, so
/// every exit — `?`, a returned `Err`, or the task being aborted at any of the
/// await points — closes it. That is the single mechanism; there is
/// deliberately no separate close on the error paths. Without it, a space that
/// keeps timing out in `wait_for_client_ready` (a large space still indexing on
/// first boot is the likeliest trigger), or one whose supervisor is aborted
/// mid-launch by an admin API space write, abandons a live renderer per attempt,
/// forever — the browser never looks dead, so nothing ever retires it.
///
/// One residual window is out of reach: an abort landing *inside* `new_page`
/// itself can leave a target that was created browser-side but whose handle
/// never reached this task. It is a single CDP round trip rather than a 60s
/// poll loop, so the exposure is tiny compared to what the guard covers.
async fn launch_page(
browser: &Browser,
page_cfg: &SpacePage,
log_console: bool,
live: &Arc<Mutex<Option<Page>>>,
ready: &Arc<AtomicBool>,
logs: &LogBuffer,
) -> Result<(), String> {
let mut guard = PageCloseGuard::new(
browser
.new_page("about:blank")
.await
.map_err(|e| format!("new page: {e}"))?,
);
let page = guard.page();
page.set_cookie(headless_cookie(page_cfg)?)
.await
.map_err(|e| format!("headless auth cookie: {e}"))?;
// Console capture is attached *before* navigating: attaching afterwards
// deterministically misses the client's earliest boot output, which is
// exactly what an operator needs to debug a space that never reaches ready.
attach_console_capture(page, logs, log_console)
.await
.map_err(|e| format!("console capture: {e}"))?;
wait_for_client_ready(&page).await?;
page.goto(page_cfg.page_url().as_str())
.await
.map_err(|e| format!("headless page navigation: {e}"))?;
*live_slot.lock().await = Some(Live { browser, page });
wait_for_client_ready(page).await?;
// Publish first, disarm second, and never await in between: from here on the
// supervisor (or `SharedChromeTransport::Drop`) closes the page via `live`.
// `Page` is `Clone` and the clones share one underlying tab, so closing any
// of them closes it.
*live.lock().await = Some(page.clone());
guard.disarm();
ready.store(true, Ordering::Relaxed);
Ok(())
}
@@ -267,29 +370,30 @@ async fn wait_for_client_ready(page: &Page) -> Result<(), String> {
}
}
/// Is the current page still alive? A trivial eval that errors means the page
/// (or the whole browser) is gone.
async fn page_is_alive(live_slot: &Arc<Mutex<Option<Live>>>) -> bool {
let guard = live_slot.lock().await;
/// Is this space's page still alive? A trivial eval that errors — or that never
/// answers — means the page (or the whole browser) is gone.
async fn page_is_alive(live: &Arc<Mutex<Option<Page>>>) -> bool {
let guard = live.lock().await;
match guard.as_ref() {
None => false,
Some(live) => eval_sync(&live.page, "1").await.is_ok(),
// A timed-out probe reports *not* alive, so the supervisor takes the
// restart path rather than waiting on a page that will never answer.
Some(page) => matches!(
tokio::time::timeout(PROBE_TIMEOUT, eval_sync(page, "1")).await,
Ok(Ok(_))
),
}
}
/// Supervise the browser for the lifetime of the transport.
///
/// Loop shape: each iteration first checks liveness. When the page is dead
/// (the initial `None` slot, or a crashed page), we mark the runtime not-ready,
/// drop any stale `Live`, and attempt `launch_once`. On success we reset the
/// backoff to its floor and continue; on failure we sleep for the current
/// backoff (2s, doubling up to 120s) before the next attempt. When the page is
/// alive we sleep ~2s between liveness checks. The browser is launched LAZILY:
/// the supervisor idles until the first runtime request (`requested`) before its
/// first `launch_once`, so Chrome never starts while the runtime API is unused.
pub async fn supervise(
config: ChromeConfig,
live_slot: Arc<Mutex<Option<Live>>>,
async fn browser_is_dead(browser: &Browser) -> bool {
browser.pages().await.is_err()
}
/// Supervise one space's page for the lifetime of its transport.
pub(crate) async fn supervise_space(
pool: Arc<crate::pool::ChromePool>,
page_cfg: SpacePage,
live: Arc<Mutex<Option<Page>>>,
ready: Arc<AtomicBool>,
logs: LogBuffer,
trigger: Arc<Notify>,
@@ -298,41 +402,65 @@ pub async fn supervise(
const BACKOFF_CAP: Duration = Duration::from_secs(120);
const LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
// Lazy launch: park here until the first runtime request, so Chrome is not
// started (and emits no CDP noise) while the runtime API is unused. `Notify`
// suspends the task with no polling; if the request arrived first,
// `notify_one` already stored a permit so this returns immediately.
trigger.notified().await;
tracing::info!(
"runtime API used; launching headless Chrome ({})",
config.chrome_path
);
let mut backoff = BACKOFF_FLOOR;
let mut has_launched = false;
loop {
if page_is_alive(&live_slot).await {
if page_is_alive(&live).await {
tokio::time::sleep(LIVENESS_INTERVAL).await;
continue;
}
// The page is dead (or never launched): mark not-ready and drop any
// stale browser before relaunching. A dead page *after* a successful
// launch is a crash/restart, worth flagging distinctly from first boot.
if has_launched {
tracing::warn!("headless Chrome page died; restarting");
tracing::warn!("headless page for {} died; restarting", page_cfg.server_url);
}
ready.store(false, Ordering::Relaxed);
*live_slot.lock().await = None;
let stale = live.lock().await.take();
if let Some(old) = stale {
let _ = tokio::time::timeout(PROBE_TIMEOUT, old.close()).await;
}
match launch_once(&config, &live_slot, &ready, &logs).await {
// `browser` is deliberately scoped to this block: the pool holds the
// owning handle, and a lingering clone here would keep a dead Chrome
// process alive across a relaunch.
let attempt = match pool.ensure_browser().await {
Ok((generation, browser)) => {
let result = launch_page(
&browser,
&page_cfg,
pool.config().log_console,
&live,
&ready,
&logs,
)
.await;
// Retire the browser only when it is genuinely gone. Discarding
// on every failed attempt would let one space's persistent
// navigation failure keep killing the browser for everyone.
let dead = result.is_err() && browser_is_dead(&browser).await;
// Drop the transient clone *before* retiring the pool's handle,
// so clearing the slot really does release the process tree.
drop(browser);
if dead {
pool.discard_browser(generation).await;
}
result
}
Err(e) => Err(e),
};
match attempt {
Ok(()) => {
has_launched = true;
backoff = BACKOFF_FLOOR;
tracing::info!("headless Chrome runtime ready");
tracing::info!("headless runtime ready for {}", page_cfg.server_url);
}
Err(e) => {
tracing::warn!("headless chrome launch failed: {e}; retrying in {backoff:?}");
tracing::warn!(
"headless page for {} failed to start: {e}; retrying in {backoff:?}",
page_cfg.server_url
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_CAP);
}
@@ -342,8 +470,190 @@ pub async fn supervise(
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use super::*;
#[derive(Clone)]
struct FakePage {
closed: Arc<AtomicUsize>,
}
impl ClosablePage for FakePage {
async fn close_page(self) {
self.closed.fetch_add(1, Ordering::SeqCst);
}
}
async fn launch_page_shaped(
page: FakePage,
live: Arc<Mutex<Option<FakePage>>>,
ready: Arc<AtomicBool>,
entered: Arc<Notify>,
hold: Arc<Notify>,
) {
let mut guard = PageCloseGuard::new(page);
let page = guard.page();
entered.notify_one();
// set_cookie / attach_console_capture / goto.
for _ in 0..3 {
tokio::task::yield_now().await;
}
// wait_for_client_ready.
hold.notified().await;
*live.lock().await = Some(page.clone());
guard.disarm();
ready.store(true, Ordering::Relaxed);
}
/// Wait (briefly) for the detached close task spawned by `Drop` to run.
async fn await_closes(closed: &Arc<AtomicUsize>, want: usize) -> usize {
for _ in 0..200 {
let n = closed.load(Ordering::SeqCst);
if n >= want {
return n;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
closed.load(Ordering::SeqCst)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn aborting_a_launch_mid_flight_closes_the_page() {
let closed = Arc::new(AtomicUsize::new(0));
let live = Arc::new(Mutex::new(None));
let ready = Arc::new(AtomicBool::new(false));
let entered = Arc::new(Notify::new());
let hold = Arc::new(Notify::new());
let task = tokio::spawn(launch_page_shaped(
FakePage {
closed: closed.clone(),
},
live.clone(),
ready.clone(),
entered.clone(),
hold.clone(),
));
// Let it reach the readiness wait, then cancel it there.
entered.notified().await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(closed.load(Ordering::SeqCst), 0, "nothing closed yet");
task.abort();
let _ = task.await;
assert_eq!(
await_closes(&closed, 1).await,
1,
"a cancelled launch must close its page, not drop the handle"
);
assert!(live.lock().await.is_none());
assert!(!ready.load(Ordering::Relaxed));
}
/// Cancellation before the readiness wait — the earlier await points
/// (`set_cookie`, console capture, `goto`) — is covered too.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn aborting_a_launch_during_setup_closes_the_page() {
let closed = Arc::new(AtomicUsize::new(0));
let live = Arc::new(Mutex::new(None));
let entered = Arc::new(Notify::new());
let hold = Arc::new(Notify::new());
let task = tokio::spawn(launch_page_shaped(
FakePage {
closed: closed.clone(),
},
live.clone(),
Arc::new(AtomicBool::new(false)),
entered.clone(),
hold,
));
// Abort as soon as the guard exists — no sleep — so the cancellation
// lands in the setup awaits rather than the readiness wait.
entered.notified().await;
task.abort();
let _ = task.await;
assert_eq!(await_closes(&closed, 1).await, 1);
assert!(live.lock().await.is_none());
}
/// The other half: a launch that *succeeds* must not close the page it just
/// published. Ownership moves to `live`, and the supervisor closes it from
/// there on restart.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_successful_launch_does_not_close_the_page() {
let closed = Arc::new(AtomicUsize::new(0));
let live = Arc::new(Mutex::new(None));
let ready = Arc::new(AtomicBool::new(false));
let entered = Arc::new(Notify::new());
let hold = Arc::new(Notify::new());
let task = tokio::spawn(launch_page_shaped(
FakePage {
closed: closed.clone(),
},
live.clone(),
ready.clone(),
entered,
hold.clone(),
));
hold.notify_one();
task.await.unwrap();
// Long enough that a stray close task would have landed by now.
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(closed.load(Ordering::SeqCst), 0);
assert!(live.lock().await.is_some());
assert!(ready.load(Ordering::Relaxed));
}
/// An `Err` return closes the tab through the same single mechanism — there
/// is deliberately no separate close on `launch_page`'s error paths.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_error_return_closes_the_page_via_the_guard() {
let closed = Arc::new(AtomicUsize::new(0));
let c = closed.clone();
let task = tokio::spawn(async move {
let guard = PageCloseGuard::new(FakePage { closed: c });
let _ = guard.page();
Err::<(), String>("headless page navigation: boom".to_string())
});
assert!(task.await.unwrap().is_err());
assert_eq!(await_closes(&closed, 1).await, 1);
}
fn page_cfg(server_url: &str) -> SpacePage {
SpacePage {
server_url: server_url.to_string(),
headless_token: "secret".to_string(),
cookie_name: "silverbullet_headless_a".to_string(),
}
}
#[test]
fn headless_cookie_is_http_only_and_named_per_space() {
let cookie = headless_cookie(&page_cfg("http://127.0.0.1:3000")).unwrap();
assert_eq!(cookie.name, "silverbullet_headless_a");
assert_eq!(cookie.value, "secret");
assert_eq!(cookie.url.as_deref(), Some("http://127.0.0.1:3000"));
assert_eq!(cookie.path.as_deref(), Some("/"));
assert_eq!(cookie.http_only, Some(true));
assert_eq!(cookie.same_site, Some(CookieSameSite::Strict));
// Session cookie: it must not outlive the browser.
assert_eq!(cookie.expires, None);
}
#[test]
fn headless_cookie_is_scoped_to_the_space_prefix() {
let cookie = headless_cookie(&page_cfg("http://127.0.0.1:3000/notes")).unwrap();
assert_eq!(cookie.path.as_deref(), Some("/notes"));
}
#[test]
fn console_level_maps_severity() {
assert_eq!(
-193
View File
@@ -1,193 +0,0 @@
//! The headless-Chrome `ClientTransport`: owns a dedicated tokio runtime and a
//! supervised browser page running the normal client in `?headless=1` mode.
//!
//! `eval_js`/`wait_ready` are synchronous and block the calling thread (the
//! server invokes them via `spawn_blocking`); they run their async work on the
//! transport's owned runtime. Launch is LAZY and tolerant: the supervisor does
//! not start Chrome until the first runtime request (`eval_js`/`wait_ready`),
//! then brings the browser up and keeps it alive — so the server can finish
//! binding its port before the headless page connects, and Chrome never runs
//! while the runtime API is unused. Until the page reports `sbRuntime.ready`,
//! `is_ready()` is false and eval/wait return `NotReady`/`Timeout` (→ the
//! server answers 503/504).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use silverbullet_server::runtime::{ClientTransport, LogBuffer, RuntimeError};
use tokio::runtime::Runtime;
use tokio::sync::{Mutex, Notify};
use crate::config::ChromeConfig;
use crate::supervisor::{eval_on_page, Live};
pub struct ChromeTransport {
rt: Option<Runtime>,
live: Arc<Mutex<Option<Live>>>,
ready: Arc<AtomicBool>,
/// Notified on the first runtime request; the supervisor parks on this
/// before launching Chrome, so the browser only starts when the runtime API
/// is actually used.
trigger: Arc<Notify>,
_supervisor: tokio::task::JoinHandle<()>,
}
impl ChromeTransport {
/// Launch Chrome and start the supervisor. Pushes console output into `logs`
/// (pass a clone of the `LogBuffer` you give to `ClientRuntime`). Returns an
/// error only if the tokio runtime can't be created — the browser launch and
/// page readiness are handled (and retried) by the supervisor, so the server
/// can finish binding its port before the headless page connects.
pub fn launch(config: ChromeConfig, logs: LogBuffer) -> Result<Self, RuntimeError> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| RuntimeError::Transport(format!("tokio runtime: {e}")))?;
let live = Arc::new(Mutex::new(None));
let ready = Arc::new(AtomicBool::new(false));
let trigger = Arc::new(Notify::new());
let supervisor = {
let (live, ready, logs, config, trigger) = (
live.clone(),
ready.clone(),
logs.clone(),
config.clone(),
trigger.clone(),
);
rt.spawn(crate::supervisor::supervise(
config, live, ready, logs, trigger,
))
};
Ok(Self {
rt: Some(rt),
live,
ready,
trigger,
_supervisor: supervisor,
})
}
/// The owned runtime. Always present until `Drop`; the `Option` exists only
/// so `Drop` can take it.
fn rt(&self) -> &Runtime {
self.rt.as_ref().expect("runtime present until drop")
}
}
impl Drop for ChromeTransport {
fn drop(&mut self) {
if let Some(rt) = self.rt.take() {
rt.shutdown_background();
}
}
}
impl ClientTransport for ChromeTransport {
fn eval_js(&self, js: &str, timeout: Duration) -> Result<Value, RuntimeError> {
// Any runtime use wakes the supervisor to launch Chrome.
self.trigger.notify_one();
let live = self.live.clone();
let js = js.to_string();
self.rt().block_on(async move {
let guard = live.lock().await;
let live = guard.as_ref().ok_or(RuntimeError::NotReady)?;
match tokio::time::timeout(timeout, eval_on_page(&live.page, &js)).await {
Err(_) => Err(RuntimeError::Timeout),
Ok(r) => r,
}
})
}
fn wait_ready(&self, timeout: Duration) -> Result<(), RuntimeError> {
// First runtime request wakes the supervisor to launch Chrome.
self.trigger.notify_one();
if self.ready.load(Ordering::Relaxed) {
return Ok(());
}
let ready = self.ready.clone();
self.rt().block_on(async move {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if ready.load(Ordering::Relaxed) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(RuntimeError::NotReady);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
}
fn is_ready(&self) -> bool {
self.ready.load(Ordering::Relaxed)
}
fn ensure_started(&self) {
// Same lazy-launch nudge as eval/wait: wake the supervisor so a
// log read alone is enough to bring Chrome up.
self.trigger.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
use silverbullet_server::runtime::LogBuffer;
/// Regression test for the Ctrl-C shutdown panic: the transport owns a
/// multi-thread runtime and is dropped while the outer server runtime is
/// still active (here, the multi-thread `#[tokio::test]` runtime, matching
/// `#[tokio::main]`).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_transport_inside_async_context_does_not_panic() {
let cfg = crate::config::ChromeConfig::resolve(
Some("/nonexistent/chrome".into()),
None,
"http://127.0.0.1:0".into(),
String::new(),
"/tmp".into(),
false,
false,
false,
true,
)
.expect("config resolves with an explicit chrome path");
let transport = ChromeTransport::launch(cfg, LogBuffer::new()).unwrap();
drop(transport); // must not panic
}
#[test]
fn eval_against_live_server_when_configured() {
let Ok(url) = std::env::var("SB_TEST_RUNTIME_URL") else {
eprintln!("skip: set SB_TEST_RUNTIME_URL to run");
return;
};
let Some(cfg) = crate::config::ChromeConfig::resolve(
None,
None,
url,
String::new(),
"/tmp/x".into(),
false,
false,
false,
true,
) else {
eprintln!("skip: no Chrome");
return;
};
let t = ChromeTransport::launch(cfg, LogBuffer::new()).unwrap();
t.wait_ready(std::time::Duration::from_secs(60)).unwrap();
let v = t
.eval_js(
"sbRuntime.evalLua(\"return 1 + 1\")",
std::time::Duration::from_secs(10),
)
.unwrap();
assert_eq!(v, serde_json::json!(2));
}
}
+117 -32
View File
@@ -1,38 +1,51 @@
use axum::http::HeaderMap;
use crate::auth::authorizer::{AuthContext, RequestAuthorizer};
use crate::auth::config::constant_time_eq;
use crate::auth::cookie::cookie_value;
/// Session-cookie name carrying a space's headless token.
///
/// Every space gets its own name. The server-managed browser is shared by all
/// spaces, so all their cookies land in one jar; `Path` scoping alone is
/// ambiguous because a space bound at the root prefix sets `Path=/` and its
/// cookie then rides along on every other space's requests. A per-space name
/// removes any dependence on the browser's cookie ordering.
pub fn headless_cookie_name(space_id: &str) -> String {
format!("silverbullet_headless_{space_id}")
}
/// Wraps an inner authorizer, additionally accepting any request that carries
/// `?token=<headless_token>` in its query string (constant-time compared). This
/// lets the headless browser page authenticate via the URL the server hands it.
/// this space's headless token in its dedicated session cookie.
pub struct HeadlessTokenAuthorizer {
inner: Box<dyn RequestAuthorizer>,
cookie_name: String,
token: String,
}
impl HeadlessTokenAuthorizer {
pub fn new(inner: Box<dyn RequestAuthorizer>, token: String) -> Self {
Self { inner, token }
pub fn new(inner: Box<dyn RequestAuthorizer>, cookie_name: String, token: String) -> Self {
Self {
inner,
cookie_name,
token,
}
}
fn query_token_matches(&self, query: Option<&str>) -> bool {
fn cookie_token_matches(&self, headers: &HeaderMap) -> bool {
if self.token.is_empty() {
return false;
}
let Some(q) = query else { return false };
for pair in q.split('&') {
if let Some(v) = pair.strip_prefix("token=") {
if constant_time_eq(v.as_bytes(), self.token.as_bytes()) {
return true;
}
}
match cookie_value(headers, &self.cookie_name) {
Some(v) => constant_time_eq(v.as_bytes(), self.token.as_bytes()),
None => false,
}
false
}
}
impl RequestAuthorizer for HeadlessTokenAuthorizer {
fn is_authorized(&self, ctx: &AuthContext) -> bool {
self.query_token_matches(ctx.query) || self.inner.is_authorized(ctx)
self.cookie_token_matches(ctx.headers) || self.inner.is_authorized(ctx)
}
}
@@ -63,35 +76,107 @@ mod tests {
}
}
/// The token is cookie-only. A query string carrying the *correct* token
/// must not authorize anything — otherwise the secret would still be
/// usable from access logs, `Referer` headers and browser history.
#[test]
fn accepts_matching_query_token() {
fn rejects_a_correct_token_in_the_query_string() {
let h = HeaderMap::new();
let a = HeadlessTokenAuthorizer::new(Box::new(DenyAll), "secret".into());
assert!(a.is_authorized(&ctx(Some("headless=1&token=secret"), &h)));
assert!(a.is_authorized(&ctx(Some("token=secret"), &h)));
let a = HeadlessTokenAuthorizer::new(
Box::new(DenyAll),
"silverbullet_headless_a".into(),
"secret".into(),
);
assert!(!a.is_authorized(&ctx(Some("headless=1&token=secret"), &h)));
assert!(!a.is_authorized(&ctx(Some("token=secret"), &h)));
}
#[test]
fn rejects_wrong_or_missing_token() {
let h = HeaderMap::new();
let a = HeadlessTokenAuthorizer::new(Box::new(DenyAll), "secret".into());
assert!(!a.is_authorized(&ctx(Some("headless=1&token=wrong"), &h)));
assert!(!a.is_authorized(&ctx(Some("token=secretX"), &h)));
assert!(!a.is_authorized(&ctx(None, &h)));
}
#[test]
fn empty_token_never_matches_query() {
let h = HeaderMap::new();
let a = HeadlessTokenAuthorizer::new(Box::new(DenyAll), String::new());
assert!(!a.is_authorized(&ctx(Some("token="), &h)));
fn rejects_wrong_or_missing_cookie_token() {
let mut wrong = HeaderMap::new();
wrong.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static("silverbullet_headless_a=secretX"),
);
let a = HeadlessTokenAuthorizer::new(
Box::new(DenyAll),
"silverbullet_headless_a".into(),
"secret".into(),
);
assert!(!a.is_authorized(&ctx(None, &wrong)));
assert!(!a.is_authorized(&ctx(None, &HeaderMap::new())));
}
#[test]
fn falls_through_to_inner_when_token_absent() {
let h = HeaderMap::new();
let a = HeadlessTokenAuthorizer::new(Box::new(AllowAll), "secret".into());
let a = HeadlessTokenAuthorizer::new(
Box::new(AllowAll),
"silverbullet_headless_a".into(),
"secret".into(),
);
// No token, but inner allows → authorized.
assert!(a.is_authorized(&ctx(None, &h)));
}
#[test]
fn cookie_name_is_per_space() {
assert_eq!(
headless_cookie_name("2f6c1e0a-1111-2222-3333-444455556666"),
"silverbullet_headless_2f6c1e0a-1111-2222-3333-444455556666"
);
assert_eq!(
headless_cookie_name("single"),
"silverbullet_headless_single"
);
}
#[test]
fn accepts_matching_cookie_token() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static("silverbullet_headless_a=secret"),
);
let a = HeadlessTokenAuthorizer::new(
Box::new(DenyAll),
"silverbullet_headless_a".into(),
"secret".into(),
);
assert!(a.is_authorized(&ctx(None, &h)));
}
/// The whole point of the per-space name: space B's cookie, riding along
/// because a root-bound space set `Path=/`, must not authorize space A.
#[test]
fn rejects_another_spaces_cookie() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static(
"silverbullet_headless_b=b-token; silverbullet_headless_a=a-token",
),
);
let a = HeadlessTokenAuthorizer::new(
Box::new(DenyAll),
"silverbullet_headless_a".into(),
"b-token".into(),
);
assert!(!a.is_authorized(&ctx(None, &h)));
}
#[test]
fn empty_token_never_matches_cookie() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static("silverbullet_headless_a="),
);
let a = HeadlessTokenAuthorizer::new(
Box::new(DenyAll),
"silverbullet_headless_a".into(),
String::new(),
);
assert!(!a.is_authorized(&ctx(None, &h)));
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub use cookie::{
auth_cookie_name, cookie_value, is_secure_request, request_host, scoped_auth_cookie_name,
CookieOptions,
};
pub use headless_token::HeadlessTokenAuthorizer;
pub use headless_token::{headless_cookie_name, HeadlessTokenAuthorizer};
pub use jwt_authorizer::JwtAuthorizer;
pub use lockout::LockoutTimer;
pub use login::LoginManager;
+12 -7
View File
@@ -13,8 +13,8 @@ use silverbullet_server_common::space::{
use silverbullet_server_common::{BootConfig, FileMeta, SpaceError, SpacePrimitives};
use crate::auth::{
AuthConfig, Authenticator, HeadlessTokenAuthorizer, JwtAuthorizer, LockoutTimer, LoginManager,
RequestAuthorizer,
headless_cookie_name, AuthConfig, Authenticator, HeadlessTokenAuthorizer, JwtAuthorizer,
LockoutTimer, LoginManager, RequestAuthorizer,
};
use crate::multi::access::{SessionPolicy, SpaceUsersAuth, UserTokenAuthorizer};
use crate::multi::config::{Binding, SpaceConfig};
@@ -32,7 +32,7 @@ pub struct AssetFactories {
/// Everything the runtime factory needs to (maybe) build a backend for a space.
pub struct RuntimeRequest<'a> {
pub space_folder: &'a str,
pub space_id: &'a str,
pub server_url: String,
pub headless_token: &'a str,
pub read_only: bool,
@@ -237,6 +237,7 @@ type AuthPair = (
/// Classic single-space authorizer/login pair: one `AuthConfig` drives both
/// the JWT authorizer (with its bearer token) and the login manager.
fn build_env_style_auth(
space_id: &str,
folder: &Path,
prefix: &str,
ac: &AuthConfig,
@@ -253,6 +254,7 @@ fn build_env_style_auth(
));
let authorizer: Arc<dyn RequestAuthorizer> = Arc::new(HeadlessTokenAuthorizer::new(
inner,
headless_cookie_name(space_id),
headless_token.to_string(),
));
let lockout = LockoutTimer::from_config(ac.lockout_time_secs, ac.lockout_limit);
@@ -346,7 +348,7 @@ fn try_build_state(
let (authorizer, login): AuthPair = match &deps.auth {
InstanceAuth::Single(None) => (None, None),
InstanceAuth::Single(Some(config)) => {
build_env_style_auth(&folder, prefix, config, &headless_token)?
build_env_style_auth(id, &folder, prefix, config, &headless_token)?
}
InstanceAuth::Accounts { .. } if config.public => (None, None),
InstanceAuth::Accounts {
@@ -366,8 +368,11 @@ fn try_build_state(
store.clone(),
member_name_filter(store.clone(), members.clone()),
));
let authorizer: Arc<dyn RequestAuthorizer> =
Arc::new(HeadlessTokenAuthorizer::new(tokens, headless_token.clone()));
let authorizer: Arc<dyn RequestAuthorizer> = Arc::new(HeadlessTokenAuthorizer::new(
tokens,
headless_cookie_name(id),
headless_token.clone(),
));
let verifier = Arc::new(SpaceUsersAuth {
store: store.clone(),
members,
@@ -406,7 +411,7 @@ fn try_build_state(
None
} else {
(deps.runtime)(&RuntimeRequest {
space_folder: &folder_str,
space_id: id,
server_url,
headless_token: &headless_token,
read_only: config.read_only,