mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
12fdc8c8e8
commit
00c59ad9c9
3 changed files with 138 additions and 62 deletions
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue