From a464a778dce3cc78d617537ce924386b50230f45 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:57:27 -0300 Subject: [PATCH 1/3] fix(browser): the app's state shows an event before a waiting tool answers The event loop woke the tools waiting for an event before it published the page info to the app. A click that opened a tab could answer the bot while the app's tab list still had one tab; the macOS runner hit it. --- crates/botloftd/src/browser/events.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index 3577092a..36e264d3 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -277,24 +277,29 @@ pub(super) async fn pump( ) { let mut last = PageInfo::default(); loop { - tokio::select! { + let handled = tokio::select! { event = events.recv() => { let Some(event) = event else { break }; session.handle(&event, &hooks).await; - session.changed.notify_waiters(); if let Some(page) = event.session.filter(|_| retitles(&event.method)) { let session = Arc::clone(&session); tokio::spawn(async move { session.retitle(&page).await }); } + true } // The owner moved between tabs: no event says so. - () = session.moved.notified() => {} - } + () = session.moved.notified() => false, + }; let info = session.lock().info(); if info != last { (hooks.changed)(info.clone()); last = info; } + // Only now: a tool that waits for this event answers after the + // app's state shows it, so the tab list is never behind the bot. + if handled { + session.changed.notify_waiters(); + } } debug!("browser: closed"); session.close(); From 67eaa694d94ae260e28c7c2287b547aac2d47699 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:01:49 -0300 Subject: [PATCH 2/3] fix(browser): the app hears of a new tab before its setup lets the page run The setup of a tab a page opened lets the page run and, when the owner watches, starts its screencast; the bot's tool could read the new page while the event loop was still in that setup, before it published the tab list. The tab is published as soon as it joins the list. --- crates/botloftd/src/browser/events.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index 36e264d3..aed1bf40 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -16,7 +16,7 @@ use super::titles::retitles; impl Session { /// Sets up a new tab before it runs, and makes it the active one. - async fn attached(&self, params: &Value) { + async fn attached(&self, params: &Value, hooks: &Hooks) { let info = ¶ms["targetInfo"]; if info["type"] != "page" { return; @@ -46,6 +46,9 @@ impl Session { let extra = (tabs.list.len() > MAX_TABS).then(|| tabs.list[0].target.clone()); (previous, tabs.watching, extra, tabs.viewport) }; + // The app hears of the tab now: the setup below lets the page run, + // and a bot's tool may read it before the setup is done. + (hooks.changed)(self.lock().info()); let setup = [ ("Page.enable", json!({})), ("Network.enable", json!({})), @@ -123,7 +126,7 @@ impl Session { let params = &event.params; let Some(session) = event.session.as_deref() else { match event.method.as_str() { - "Target.attachedToTarget" => self.attached(params).await, + "Target.attachedToTarget" => self.attached(params, hooks).await, "Target.detachedFromTarget" => self.detached(params).await, "Target.targetInfoChanged" => self.info_changed(¶ms["targetInfo"]), "Browser.downloadWillBegin" => { From 09b60247209d2a7c27d428e91413e27ca1ae8029 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:22:25 -0300 Subject: [PATCH 3/3] test(browser): wait for the app's tab list to catch up with the new tab The address of a tab a page opened reaches the app through events that wait while the tab is set up, so the app may show it a moment after the bot read the page. The second macOS run showed the tab without it yet. --- crates/botloftd/tests/browser.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/botloftd/tests/browser.rs b/crates/botloftd/tests/browser.rs index 9b37d21a..9b69d4e4 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -182,13 +182,29 @@ async fn new_tabs_and_dialogs_reach_the_bot_and_the_owner_watches() { .await .expect("new tab"); assert!(tab.contains("Thanks, Tab"), "{tab}"); - let open = b.app.call("browser.list", Value::Null).await.expect("list"); - // The new tab is the active one, after the tab that opened it. - let tabs = open[0]["tabs"].as_array().expect("tabs"); + // The new tab is the active one, after the tab that opened it. The app + // may hear of its address a moment after the bot read the page: the + // events that bring it wait while the new tab is set up. + let deadline = Instant::now() + Duration::from_secs(5); + let tabs = loop { + let open = b.app.call("browser.list", Value::Null).await.expect("list"); + let tabs = open[0]["tabs"].as_array().expect("tabs").clone(); + let shown = tabs.len() == 2 + && tabs[1]["url"] + .as_str() + .is_some_and(|url| url.contains("/done")); + if shown || Instant::now() > deadline { + break tabs; + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; assert_eq!(tabs.len(), 2, "{tabs:?}"); assert_eq!(tabs[0]["active"], false); assert_eq!(tabs[1]["active"], true); - assert!(tabs[1]["url"].as_str().expect("url").contains("/done")); + assert!( + tabs[1]["url"].as_str().expect("url").contains("/done"), + "{tabs:?}" + ); let picture = b.mcp.tool_result("browser_screenshot", json!({})).await; assert_eq!(picture["content"][0]["type"], "image");