fix(stockbit): tell a polling agent when Stockbit refuses the sign-in

A refused cookie in idx's browser was only a stderr warning, which nobody
reads under idx-mcp: stockbit_status showed `waiting` until the timeout, and
the timeout message did not mention the refusal.

- runtime::on_warning sends a thread's warnings to a sink as well as
  stderr, until its guard drops.
- The background sign-in puts its warnings in `sign_in.warning`, cleared
  once it signs in.
- The errors that end a browser wait (timeout, window closed, browser
  exited) name a refusal it hit.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 19:22:14 +07:00
commit 362d314118
8 changed files with 130 additions and 18 deletions

View file

@ -234,6 +234,9 @@ fn wait_for_session(
) -> Result<(), IdxError> {
let deadline = Instant::now() + timeout;
let mut rejected: Option<String> = None;
// Why Stockbit refused that cookie, so the error that ends the wait says
// so: a polling frontend never saw the warning.
let mut refusal: Option<String> = None;
// After a check fails for a passing reason, the next is held back.
let mut retry_at: Option<Instant> = None;
let mut last_error: Option<String> = None;
@ -273,6 +276,7 @@ fn wait_for_session(
"Stockbit refused the session in idx's browser ({err}); sign in \
again in that window"
));
refusal = Some(err.to_string());
rejected = Some(cookie.value);
retry_at = None;
}
@ -283,12 +287,18 @@ fn wait_for_session(
Ok(_) => last_error = None,
Err(err) => last_error = Some(err.to_string()),
}
let refused = || {
refusal
.as_ref()
.map(|err| format!("; Stockbit refused the session signed in there ({err})"))
.unwrap_or_default()
};
if !alive() {
devtools.close();
return Err(IdxError::Io(
"the browser exited before idx got a session; run `idx stockbit login` again"
.to_string(),
));
return Err(IdxError::Io(format!(
"the browser exited before idx got a session{}; run `idx stockbit login` again",
refused()
)));
}
match devtools.has_page() {
Ok(true) => {
@ -299,11 +309,11 @@ fn wait_for_session(
misses += 1;
if saw_window && misses >= 2 {
devtools.close();
return Err(IdxError::Io(
"the Stockbit sign-in window was closed before the session arrived; \
run `idx stockbit login` again"
.to_string(),
));
return Err(IdxError::Io(format!(
"the Stockbit sign-in window was closed before the session arrived{}; \
run `idx stockbit login` again",
refused()
)));
}
}
Err(err) => last_error = Some(err.to_string()),
@ -311,9 +321,10 @@ fn wait_for_session(
if Instant::now() >= deadline {
devtools.close();
let mut message = format!(
"the Stockbit sign-in timed out after {}s and idx closed its browser window; \
"the Stockbit sign-in timed out after {}s and idx closed its browser window{}; \
re-run `idx stockbit login`, or add --no-browser for the local page",
timeout.as_secs()
timeout.as_secs(),
refused()
);
if let Some(err) = last_error {
message.push_str(&format!(" (last DevTools error: {err})"));
@ -993,6 +1004,11 @@ mod tests {
)
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(
err.to_string()
.contains("Stockbit refused the session signed in there (auth error: expired)"),
"{err}"
);
assert_eq!(checks.get(), 1, "one refusal per distinct cookie");
browser.finish();
let _ = std::fs::remove_dir_all(&profile);

View file

@ -242,6 +242,10 @@ pub struct Progress {
/// Why it failed: the error's code and message.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ProgressError>,
/// The latest warning while it waits, such as Stockbit refusing the
/// session signed in so far; gone once it signs in.
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@ -257,6 +261,7 @@ struct Slot {
timeout: Duration,
state: State,
error: Option<ProgressError>,
warning: Option<String>,
cancel: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
@ -273,6 +278,7 @@ impl Slot {
expires_in_secs,
already_open,
error: self.error.clone(),
warning: self.warning.clone(),
}
}
@ -303,6 +309,7 @@ pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
expires_in_secs: 0,
already_open: false,
error: None,
warning: None,
});
}
let sign_in = SignIn::start(options)?;
@ -315,16 +322,29 @@ pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
timeout: sign_in.timeout(),
state: State::Waiting,
error: None,
warning: None,
cancel: cancel.clone(),
thread: None,
};
let progress = open.progress(false);
open.thread = Some(std::thread::spawn(move || {
// Nobody reads a polling frontend's stderr, so what the wait warns
// about (a refused session) goes into the progress it polls.
let sink = crate::runtime::on_warning(move |message| {
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
open.warning = Some(message.to_string());
}
});
let result = sign_in.wait_until(&cancel);
drop(sink);
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
match result {
Ok(_) => open.state = State::SignedIn,
Ok(_) => {
open.state = State::SignedIn;
open.warning = None;
}
Err(err) => {
open.state = State::Failed;
open.error = Some(ProgressError {
@ -398,12 +418,17 @@ mod tests {
expires_in_secs: 300,
already_open: false,
error: None,
warning: None,
};
let value = serde_json::to_value(&page).unwrap();
assert_eq!(value["state"], "waiting");
assert_eq!(value["method"], "page");
assert_eq!(value["port"], 7878);
assert!(value.get("reason").is_none() && value.get("error").is_none());
assert!(
["reason", "error", "warning"]
.iter()
.all(|key| value.get(key).is_none())
);
let failed = Progress {
state: State::Failed,
@ -416,10 +441,12 @@ mod tests {
code: "IO".to_string(),
message: "the window was closed".to_string(),
}),
warning: Some("Stockbit refused the session".to_string()),
};
let value = serde_json::to_value(&failed).unwrap();
assert_eq!(value["method"], "browser");
assert_eq!(value["browser"], "Helium");
assert_eq!(value["error"]["code"], "IO");
assert_eq!(value["warning"], "Stockbit refused the session");
}
}

View file

@ -22,12 +22,40 @@ pub mod text;
pub use client::{CachePolicy, Fetched, Idx};
pub mod runtime {
use std::cell::RefCell;
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
static QUIET: AtomicBool = AtomicBool::new(false);
static JSON: AtomicBool = AtomicBool::new(false);
type Sink = Box<dyn Fn(&str)>;
thread_local! {
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
}
/// Sends this thread's warnings to `sink` too, until the guard drops: a
/// frontend whose stderr nobody reads (idx-mcp) hands them on this way.
/// Warnings still go to stderr, and `set_quiet` does not silence `sink`.
pub fn on_warning(sink: impl Fn(&str) + 'static) -> WarningSink {
let previous = SINK.with(|slot| slot.replace(Some(Box::new(sink))));
WarningSink { previous }
}
/// Restores the thread's previous sink when dropped.
#[must_use = "warnings reach the sink only while this guard lives"]
pub struct WarningSink {
previous: Option<Sink>,
}
impl Drop for WarningSink {
fn drop(&mut self) {
let previous = self.previous.take();
SINK.with(|slot| *slot.borrow_mut() = previous);
}
}
pub fn set_quiet(quiet: bool) {
QUIET.store(quiet, Ordering::Relaxed);
}
@ -62,6 +90,40 @@ pub mod runtime {
}
pub fn warn(message: impl Display) {
let message = message.to_string();
SINK.with(|slot| {
if let Some(sink) = slot.borrow().as_ref() {
sink(&message);
}
});
emit("warning", message);
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
/// A sink sees this thread's warnings while its guard lives, and an
/// inner one hands back to the outer when it drops.
#[test]
fn warnings_reach_the_sink_while_its_guard_lives() {
let seen = Rc::new(RefCell::new(Vec::new()));
let outer = {
let seen = seen.clone();
on_warning(move |m| seen.borrow_mut().push(format!("outer {m}")))
};
warn("one");
{
let seen = seen.clone();
let _inner = on_warning(move |m| seen.borrow_mut().push(format!("inner {m}")));
warn("two");
}
warn("three");
drop(outer);
warn("four");
info("not a warning");
assert_eq!(*seen.borrow(), ["outer one", "inner two", "outer three"]);
}
}
}

View file

@ -189,7 +189,9 @@ IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Withou
get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the \
user adds one with stockbit_login, or `idx stockbit login` in a terminal. After \
stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed \
(with `sign_in.error`).")]
(with `sign_in.error`). While it waits, `sign_in.warning` is the latest problem it hit, such \
as Stockbit refusing the session the user signed in with: pass it on, since the user may need \
to sign in again.")]
async fn stockbit_status(&self) -> ToolResult {
blocking(login_status).await
}

View file

@ -604,7 +604,7 @@
"name": "stockbit_login"
},
{
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with stockbit_login, or `idx stockbit login` in a terminal. After stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed (with `sign_in.error`).",
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with stockbit_login, or `idx stockbit login` in a terminal. After stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed (with `sign_in.error`). While it waits, `sign_in.warning` is the latest problem it hit, such as Stockbit refusing the session the user signed in with: pass it on, since the user may need to sign in again.",
"inputSchema": {
"properties": {},
"type": "object"