fix(stockbit): answer login-page connections concurrently

Browsers open idle preconnects; serving one connection at a time made the
page wait out the 3 s read timeout on each, which read as "can't
connect". Each connection now gets its own thread (capped) and a silent
one is dropped. Regression test; measured 10.1 s -> 0.08 s with three
idle sockets. Docs cover a browser on another machine (--port + ssh -L).
This commit is contained in:
hermes 2026-10-23 13:28:12 +00:00
commit 00c59ad9c9
3 changed files with 138 additions and 62 deletions

View file

@ -111,7 +111,7 @@ fn login_in_browser(port: u16, open: bool) -> Result<(), IdxError> {
if open { if open {
open_in_browser(&page.url()); open_in_browser(&page.url());
} }
page.wait(LOGIN_TIMEOUT, &|pasted| { page.wait(LOGIN_TIMEOUT, |pasted| {
stockbit::login(pasted) stockbit::login(pasted)
.map(|_| ()) .map(|_| ())
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())

View file

@ -23,8 +23,11 @@ use crate::error::IdxError;
/// Largest paste accepted (the cookie is a few KB). /// Largest paste accepted (the cookie is a few KB).
const MAX_BODY: usize = 16 * 1024; const MAX_BODY: usize = 16 * 1024;
/// Requests time out instead of pinning a thread. /// Requests time out instead of pinning a thread; short, because a browser
const IO_TIMEOUT: Duration = Duration::from_secs(10); /// preconnect sends nothing at all.
const IO_TIMEOUT: Duration = Duration::from_secs(3);
/// Connections answered at once; beyond this, new ones are dropped.
const MAX_CONNECTIONS: usize = 16;
/// A running login page. /// A running login page.
pub struct LoginPage { pub struct LoginPage {
@ -61,50 +64,96 @@ impl LoginPage {
/// Serves until `verify` accepts a paste or `timeout` passes. `verify` /// Serves until `verify` accepts a paste or `timeout` passes. `verify`
/// returns the error text to show on the page when the paste is refused. /// returns the error text to show on the page when the paste is refused.
pub fn wait( ///
&self, /// Each connection gets its own thread: browsers open idle preconnects,
timeout: Duration, /// and answering them one at a time used to hold the page back for the
verify: &dyn Fn(&str) -> Result<(), String>, /// read timeout on every one.
) -> Result<(), IdxError> { pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
where
F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
{
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let verify = std::sync::Arc::new(verify);
let page = std::sync::Arc::new(self.page());
let path = std::sync::Arc::new(format!("/login/{}", self.secret));
let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
while Instant::now() < deadline { loop {
match self.listener.accept() { if done_rx.try_recv().is_ok() {
Ok((stream, _)) => {
if self.serve(stream, verify) {
return Ok(()); return Ok(());
} }
if Instant::now() >= deadline {
break;
}
match self.listener.accept() {
Ok((stream, _)) => {
// Browsers open a handful; anything beyond this is junk.
if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
continue;
}
busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (verify, page, path) = (verify.clone(), page.clone(), path.clone());
let busy = busy.clone();
let done = done_tx.clone();
std::thread::spawn(move || {
if serve_once(stream, &path, &page, verify.as_ref()) {
let _ = done.send(());
}
busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
});
} }
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(40)); std::thread::sleep(Duration::from_millis(20));
} }
Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))), Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))),
} }
} }
Err(IdxError::Io(format!( Err(IdxError::Io(format!(
"the Stockbit login page timed out after {}s", "the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \
timeout.as_secs() port with --port)",
timeout.as_secs(),
self.url()
))) )))
} }
/// Answers one request; true when a paste was accepted. /// The page, with this run's port and secret baked into the bookmarklet.
fn serve(&self, mut stream: TcpStream, verify: &dyn Fn(&str) -> Result<(), String>) -> bool { fn page(&self) -> String {
let target = self.url();
// Reads stockbit.com's session cookie and opens the page with it in
// the fragment (never sent to a server, never in a referrer).
let bookmarklet = format!(
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
);
PAGE.replace("__TARGET__", &target)
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
}
}
/// Answers one request; true when a paste was accepted.
fn serve_once(
mut stream: TcpStream,
page_path: &str,
page: &str,
verify: &dyn Fn(&str) -> Result<(), String>,
) -> bool {
let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let Some(request) = read_request(&mut stream) else { let Some(request) = read_request(&mut stream) else {
respond(&mut stream, 400, "text/plain", b"bad request"); // An idle preconnect or a malformed request: drop it quietly.
return false; return false;
}; };
let page = format!("/login/{}", self.secret);
match (request.method.as_str(), request.path.as_str()) { match (request.method.as_str(), request.path.as_str()) {
("GET", p) if p == page => { ("GET", p) if p == page_path => {
respond( respond(
&mut stream, &mut stream,
200, 200,
"text/html; charset=utf-8", "text/html; charset=utf-8",
self.page().as_bytes(), page.as_bytes(),
); );
} }
("POST", p) if p == page => match verify(&request.body) { ("POST", p) if p == page_path => match verify(&request.body) {
Ok(()) => { Ok(()) => {
respond( respond(
&mut stream, &mut stream,
@ -128,21 +177,6 @@ impl LoginPage {
_ => respond(&mut stream, 404, "text/plain", b"not found"), _ => respond(&mut stream, 404, "text/plain", b"not found"),
} }
false false
}
/// The page, with this run's port and secret baked into the bookmarklet.
fn page(&self) -> String {
let target = self.url();
// Reads stockbit.com's session cookie and opens the page with it in
// the fragment (never sent to a server, never in a referrer).
let bookmarklet = format!(
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
);
PAGE.replace("__TARGET__", &target)
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
}
} }
fn random_secret() -> String { fn random_secret() -> String {
@ -357,7 +391,7 @@ mod tests {
"good" => Ok(()), "good" => Ok(()),
_ => Err("not a Stockbit session".to_string()), _ => Err("not a Stockbit session".to_string()),
}; };
page.wait(Duration::from_secs(10), &verify) page.wait(Duration::from_secs(10), verify)
}); });
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
@ -385,9 +419,41 @@ mod tests {
let page = LoginPage::bind(0).unwrap(); let page = LoginPage::bind(0).unwrap();
let started = Instant::now(); let started = Instant::now();
let err = page let err = page
.wait(Duration::from_millis(150), &|_| Err("no".to_string())) .wait(Duration::from_millis(150), |_| Err("no".to_string()))
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}"); assert!(err.to_string().contains("timed out"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5)); assert!(started.elapsed() < Duration::from_secs(5));
} }
/// Browsers open idle preconnects; one must not delay the page.
#[test]
fn an_idle_connection_does_not_delay_the_page() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), |_| {
Err("not a Stockbit session".to_string())
})
});
std::thread::sleep(Duration::from_millis(100));
// Two sockets that say nothing, as Chrome's preconnects do.
let rest = url.trim_start_matches("http://");
let (authority, _) = rest.split_once('/').unwrap();
let idle: Vec<TcpStream> = (0..2)
.map(|_| TcpStream::connect(authority).unwrap())
.collect();
std::thread::sleep(Duration::from_millis(100));
let started = Instant::now();
let (status, _) = request(&url, "GET", "");
assert_eq!(status, 200);
assert!(
started.elapsed() < Duration::from_secs(2),
"page waited {:?} behind idle connections",
started.elapsed()
);
drop(idle);
drop(handle);
}
} }

View file

@ -94,8 +94,18 @@ refresh token on its own. On a server, set `IDX_STOCKBIT_REFRESH_TOKEN`.
The session lands in `~/.config/idx/stockbit-session.json`, readable only by The session lands in `~/.config/idx/stockbit-session.json`, readable only by
you. Tokens never appear in output, logs or errors. The page takes only the you. Tokens never appear in output, logs or errors. The page takes only the
session value, keeps it in memory, and closes when it is done (or after five session value, keeps it in memory, and closes when it is done (or after five
minutes). A session Stockbit refuses leaves a previously stored one in minutes). A session Stockbit refuses leaves a previously stored one in place.
place.
The page listens on this machine's loopback interface only, so the browser
must run on the same machine: from another host over SSH, or from a browser
outside a container or WSL distro, `127.0.0.1` is a different machine and the
page will not load. Forward it instead, with a pinned port:
```bash
idx stockbit login --no-browser --port 7878
# from the machine with the browser:
ssh -L 7878:127.0.0.1:7878 <this-host> # then open the printed URL
```
**One refresher per session.** Stockbit replaces the refresh token every time **One refresher per session.** Stockbit replaces the refresh token every time
it is used. Once idx refreshes your session, a browser holding the same it is used. Once idx refreshes your session, a browser holding the same