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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions baml_language/crates/baml_lsp_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ fn run_server_inner(
print_playground_banner(playground_port, &workspace_roots, session_token.as_deref());
}

// Tracks the target of the most recent OpenPlayground so browser-mode pages
// that connect after the request can be navigated to it (see the sender and
// the WS `RequestState` handler). Shared between the sender and the server.
let current_open_target: playground_sender::SharedOpenTarget =
Arc::new(std::sync::Mutex::new(None));

// Playground sender (needs port + lsp_sender for OpenPlayground)
let playground_sender: Arc<dyn bex_project::PlaygroundSender> =
Arc::new(playground_sender::NativePlaygroundSender::new(
Expand All @@ -272,6 +278,7 @@ fn run_server_inner(
playground_port,
matches!(playground_open_target, PlaygroundOpenTarget::Browser),
session_token.clone(),
current_open_target.clone(),
));

// Create the BexLsp (multi-project LSP)
Expand Down Expand Up @@ -363,6 +370,7 @@ fn run_server_inner(
doc_mirror,
workspace_roots.clone(),
session_token,
current_open_target.clone(),
));
}

Expand All @@ -380,6 +388,7 @@ fn run_server_inner(
doc_mirror,
workspace_roots_for_server,
session_token,
current_open_target.clone(),
)
.await
{
Expand Down
189 changes: 173 additions & 16 deletions baml_language/crates/baml_lsp_server/src/playground_sender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,42 @@
//! playground notifications through a `tokio::sync::broadcast` channel
//! that WebSocket clients subscribe to.
//!
//! `OpenPlayground` is special: instead of going over WebSocket it either
//! opens the system browser for `baml playground` or sends an LSP
//! notification to the editor client.
//! `OpenPlayground` is special. In editor mode it sends an LSP notification to
//! the editor client. In browser mode (`baml playground`) it navigates the
//! open playground page over the WebSocket and only spawns a system browser
//! window when no page is connected, so repeated opens reuse one window
//! instead of stacking new ones.

use std::sync::Arc;
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};

use tokio::sync::broadcast;

use crate::playground_ws::WsOutMessage;

/// The playground target most recently requested via `OpenPlayground`.
///
/// Browser mode records this so a page that connects *after* the request
/// (a freshly spawned window, or a reconnect) can be navigated to the same
/// target when it asks for state. See the `RequestState` handler.
#[derive(Clone, Default)]
pub struct OpenPlaygroundTarget {
pub project: String,
pub function_name: Option<String>,
pub test_name: Option<String>,
pub testset_name: Option<String>,
}

/// Shared between the sender (writer) and the WS server (replays it on
/// connect). `None` until the first `OpenPlayground` in a browser session.
pub type SharedOpenTarget = Arc<Mutex<Option<OpenPlaygroundTarget>>>;

/// A second browser window must not be spawned while the first is still
/// loading (before it connects and bumps `receiver_count`).
const BROWSER_SPAWN_DEBOUNCE: Duration = Duration::from_secs(5);

pub struct NativePlaygroundSender {
broadcast_tx: broadcast::Sender<WsOutMessage>,
lsp_sender: Arc<dyn bex_project::LspClientSenderTrait + Send + Sync>,
Expand All @@ -22,6 +48,10 @@ pub struct NativePlaygroundSender {
/// Browser-mode session token; carried on the opened URL so the page can
/// authorize its /api requests.
session_token: Option<Arc<str>>,
/// Last requested open target, shared with the WS server for replay.
current_open_target: SharedOpenTarget,
/// When we last spawned a browser window, to debounce double-opens.
last_browser_open: Mutex<Option<Instant>>,
}

impl NativePlaygroundSender {
Expand All @@ -31,14 +61,31 @@ impl NativePlaygroundSender {
playground_port: u16,
open_in_browser: bool,
session_token: Option<Arc<str>>,
current_open_target: SharedOpenTarget,
) -> Self {
Self {
broadcast_tx,
lsp_sender,
playground_port,
open_in_browser,
session_token,
current_open_target,
last_browser_open: Mutex::new(None),
}
}

/// Returns true if this call should spawn a browser window. Debounces
/// rapid `OpenPlayground`s that arrive before the first window connects.
fn claim_browser_open(&self) -> bool {
let mut guard = self.last_browser_open.lock().unwrap();
let now = Instant::now();
if let Some(prev) = *guard
&& now.duration_since(prev) < BROWSER_SPAWN_DEBOUNCE
{
return false;
}
*guard = Some(now);
true
}
}

Expand All @@ -52,19 +99,43 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender {
} = notification
{
if self.open_in_browser {
let url = match &self.session_token {
Some(token) => {
format!("http://localhost:{}/?token={token}", self.playground_port)
}
None => format!("http://localhost:{}", self.playground_port),
};
// `webbrowser::open` can block until a text-mode browser (lynx/w3m)
// exits on headless hosts; never hold up server startup on it.
std::thread::spawn(move || {
if let Err(e) = webbrowser::open(&url) {
tracing::error!("Failed to open browser at {}: {}", url, e);
}
// Remember where to navigate so a page that connects after this
// request (fresh window or reconnect) can be sent here on
// `RequestState`.
*self.current_open_target.lock().unwrap() = Some(OpenPlaygroundTarget {
project: project.clone(),
function_name: function_name.clone(),
test_name: test_name.clone(),
testset_name: testset_name.clone(),
});

// Navigate any already-open page in place rather than spawning a
// new window: pages listen on this broadcast and react to the
// `openPlayground` notification.
let json = serde_json::to_value(&notification).unwrap_or_default();
let _ = self
.broadcast_tx
.send(WsOutMessage::PlaygroundNotification { notification: json });

// Only open a browser window when nothing is connected. The
// debounce avoids a second window while the first is still
// loading (before it connects and bumps `receiver_count`).
if self.broadcast_tx.receiver_count() == 0 && self.claim_browser_open() {
let url = match &self.session_token {
Some(token) => {
format!("http://localhost:{}/?token={token}", self.playground_port)
}
None => format!("http://localhost:{}", self.playground_port),
};
// `webbrowser::open` can block until a text-mode browser
// (lynx/w3m) exits on headless hosts; never hold up the
// server on it.
std::thread::spawn(move || {
if let Err(e) = webbrowser::open(&url) {
tracing::error!("Failed to open browser at {}: {}", url, e);
}
});
}
} else {
let params = serde_json::json!({
"port": self.playground_port,
Expand Down Expand Up @@ -98,3 +169,89 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender {
.send(WsOutMessage::PlaygroundNotification { notification: json });
}
}

#[cfg(test)]
mod tests {
use bex_project::PlaygroundSender;

use super::*;

struct NoopLspSender;
impl bex_project::LspClientSenderTrait for NoopLspSender {
fn send_notification(
&self,
_: lsp_server::Notification,
) -> Result<(), bex_project::LspError> {
Ok(())
}
fn send_response_impl(&self, _: lsp_server::Response) -> Result<(), bex_project::LspError> {
Ok(())
}
fn make_request(&self, _: lsp_server::Request) -> Result<(), bex_project::LspError> {
Ok(())
}
}

fn browser_sender(
broadcast_tx: broadcast::Sender<WsOutMessage>,
target: SharedOpenTarget,
) -> NativePlaygroundSender {
NativePlaygroundSender::new(
broadcast_tx,
Arc::new(NoopLspSender),
4265,
true, // browser mode
None,
target,
)
}

/// Browser-mode OpenPlayground navigates the already-open page in place and
/// records the target for replay — the core of B-808. Holding a live
/// receiver keeps `receiver_count() > 0`, which both exercises the reuse
/// path and guarantees the test never launches a real browser.
#[test]
fn browser_open_playground_broadcasts_in_place_and_records_target() {
let (tx, mut rx) = broadcast::channel(8);
let target: SharedOpenTarget = Arc::new(Mutex::new(None));
let sender = browser_sender(tx, target.clone());

sender.send_playground_notification(bex_project::PlaygroundNotification::OpenPlayground {
project: "/tmp/proj".to_string(),
function_name: Some("Foo".to_string()),
test_name: None,
testset_name: None,
});

let WsOutMessage::PlaygroundNotification { notification } = rx
.try_recv()
.expect("open should broadcast to the connected page")
else {
panic!("expected a playground notification");
};
assert_eq!(notification["type"], "openPlayground");
assert_eq!(notification["project"], "/tmp/proj");
assert_eq!(notification["functionName"], "Foo");

let recorded = target
.lock()
.unwrap()
.clone()
.expect("target recorded for RequestState replay");
assert_eq!(recorded.project, "/tmp/proj");
assert_eq!(recorded.function_name.as_deref(), Some("Foo"));
}

/// Two OpenPlaygrounds arriving before the first window connects must not
/// spawn two browser windows.
#[test]
fn claim_browser_open_debounces_rapid_spawns() {
let (tx, _rx) = broadcast::channel(8);
let sender = browser_sender(tx, Arc::new(Mutex::new(None)));
assert!(sender.claim_browser_open(), "first spawn allowed");
assert!(
!sender.claim_browser_open(),
"second spawn within debounce suppressed"
);
}
}
26 changes: 26 additions & 0 deletions baml_language/crates/baml_lsp_server/src/playground_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ struct WsState {
doc_mirror: DocMirror,
/// Workspace roots that browser-mode LSP saves are allowed to write under.
workspace_roots: Arc<Vec<PathBuf>>,
/// Target of the most recent OpenPlayground; replayed to a page when it
/// requests state so a freshly opened / reconnected window navigates there.
current_open_target: crate::playground_sender::SharedOpenTarget,
}

type LiveValueStore = Arc<Mutex<LiveValueCache>>;
Expand Down Expand Up @@ -969,6 +972,7 @@ pub async fn run(
doc_mirror: DocMirror,
workspace_roots: Vec<PathBuf>,
session_token: Option<Arc<str>>,
current_open_target: crate::playground_sender::SharedOpenTarget,
) -> anyhow::Result<()> {
let local_addr = listener.local_addr()?;
let access_guard = PlaygroundAccessGuard::new(session_token);
Expand All @@ -983,6 +987,7 @@ pub async fn run(
doc_mirror,
Arc::new(workspace_roots),
access_guard,
current_open_target,
)?;

tracing::info!("Playground: http://localhost:{}", local_addr.port());
Expand All @@ -1004,6 +1009,7 @@ fn build_router(
doc_mirror: DocMirror,
workspace_roots: Arc<Vec<PathBuf>>,
access_guard: PlaygroundAccessGuard,
current_open_target: crate::playground_sender::SharedOpenTarget,
) -> anyhow::Result<Router> {
let value_store = Arc::new(Mutex::new(LiveValueCache::with_max_bytes(
DEFAULT_NATIVE_LIVE_VALUE_CACHE_BYTES,
Expand All @@ -1022,6 +1028,7 @@ fn build_router(
lsp_out_tx,
doc_mirror,
workspace_roots,
current_open_target,
};

let api = Router::new()
Expand Down Expand Up @@ -2365,6 +2372,25 @@ async fn handle_ws_in_message(

WsInMessage::RequestState => {
state.bex.request_playground_state();
// A page that connected after an OpenPlayground request (a freshly
// spawned browser window, or a reconnect) still needs to be told
// where to navigate. Replay the last target directly to it.
let target = state.current_open_target.lock().unwrap().clone();
if let Some(target) = target {
let notif = bex_project::PlaygroundNotification::OpenPlayground {
project: target.project,
function_name: target.function_name,
test_name: target.test_name,
testset_name: target.testset_name,
};
let json = serde_json::to_value(&notif).unwrap_or_default();
let msg = WsOutMessage::PlaygroundNotification { notification: json };
if let Some(ws_msg) = to_ws_text(&msg)
&& sink.send(ws_msg).await.is_err()
{
tracing::warn!("Failed to replay open-playground target");
}
}
}

WsInMessage::RequestCollectTests { project } => {
Expand Down
Loading