Skip to content

Commit 9165d9b

Browse files
authored
Merge pull request #81 from httpsphl/perf/rpc-off-loop
perf(rpc): answer requests on the blocking pool; slow reads aside
2 parents e4d7ffa + 90b8e05 commit 9165d9b

5 files changed

Lines changed: 203 additions & 49 deletions

File tree

‎crates/botloftd/src/rpc/jsonrpc.rs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! notifications.
33
44
use botloft_core::protocol::error_code;
5+
use serde::Serialize;
56
use serde_json::{Map, Value, json};
67

78
use crate::service::ApiError;
@@ -104,8 +105,23 @@ pub fn failure(id: &Value, err: &RpcError) -> String {
104105
json!({ "jsonrpc": "2.0", "id": id, "error": error }).to_string()
105106
}
106107

107-
pub fn notification(method: &str, params: Value) -> String {
108-
json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string()
108+
/// A notification, written straight to text without building a `Value`.
109+
pub fn notification(method: &str, params: &impl Serialize) -> String {
110+
#[derive(Serialize)]
111+
struct Notification<'a, P> {
112+
jsonrpc: &'static str,
113+
method: &'a str,
114+
params: &'a P,
115+
}
116+
let note = Notification {
117+
jsonrpc: "2.0",
118+
method,
119+
params,
120+
};
121+
serde_json::to_string(&note).unwrap_or_else(|err| {
122+
tracing::warn!(method, "could not write a notification: {err}");
123+
json!({ "jsonrpc": "2.0", "method": method, "params": null }).to_string()
124+
})
109125
}
110126

111127
/// Params for methods that take none.
@@ -182,7 +198,7 @@ mod tests {
182198
let failed: Value = serde_json::from_str(&failure(&json!(1), &err)).expect("json");
183199
assert_eq!(failed["error"], json!({"code":-32002,"message":"missing"}));
184200
let note: Value =
185-
serde_json::from_str(&notification("crew.changed", json!({}))).expect("json");
201+
serde_json::from_str(&notification("crew.changed", &json!({}))).expect("json");
186202
assert_eq!(
187203
note,
188204
json!({"jsonrpc":"2.0","method":"crew.changed","params":{}})

‎crates/botloftd/src/rpc/mod.rs‎

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,20 @@ async fn run(
134134
outbox: &mpsc::Sender<String>,
135135
frames: &watch::Sender<Option<String>>,
136136
mut events: broadcast::Receiver<Event>,
137-
daemon: &Daemon,
137+
daemon: &Arc<Daemon>,
138138
) {
139139
let mut watch = Watch::default();
140140
loop {
141141
let frame = tokio::select! {
142142
message = stream.next() => match message {
143-
Some(Ok(Message::Text(text))) => handle(daemon, &text, &mut watch, frames),
143+
Some(Ok(Message::Text(text))) => match route(daemon, &text, &mut watch, frames) {
144+
Routed::Reply(frame) => frame,
145+
Routed::InOrder(request) => answer(daemon, request).await,
146+
Routed::Aside(request) => {
147+
aside(daemon, request, outbox.clone());
148+
None
149+
}
150+
},
144151
Some(Ok(Message::Close(_)) | Err(_)) | None => return,
145152
Some(Ok(_)) => None,
146153
},
@@ -166,18 +173,40 @@ async fn run(
166173
}
167174
}
168175

169-
fn handle(
176+
/// Reads that the app never sets against notifications: their answer may
177+
/// come after newer notifications, so they wait neither for the requests
178+
/// before them nor hold back the notifications meanwhile.
179+
const ASIDE: [&str; 5] = [
180+
method::FILES_LIST,
181+
method::FILES_READ,
182+
method::SCREENS_LIST,
183+
method::ATTACHMENTS_READ,
184+
method::USAGE_TOKENS,
185+
];
186+
187+
enum Routed {
188+
/// Answered here, or nothing to answer.
189+
Reply(Option<String>),
190+
/// Answered before anything else goes out (see [`answer`]).
191+
InOrder(jsonrpc::Request),
192+
/// Answered whenever it is ready (see [`ASIDE`]).
193+
Aside(jsonrpc::Request),
194+
}
195+
196+
fn route(
170197
daemon: &Daemon,
171198
text: &str,
172199
watch: &mut Watch,
173200
frames: &watch::Sender<Option<String>>,
174-
) -> Option<String> {
201+
) -> Routed {
175202
let request = match jsonrpc::parse(text) {
176203
Ok(request) => request,
177-
Err((id, err)) => return Some(jsonrpc::failure(&id, &err)),
204+
Err((id, err)) => return Routed::Reply(Some(jsonrpc::failure(&id, &err))),
178205
};
179206
debug!(method = %request.method, "rpc request");
180-
let id = request.id.clone()?;
207+
let Some(id) = request.id.clone() else {
208+
return Routed::Reply(None);
209+
};
181210
// Watching a browser belongs to this connection (spec 21.7).
182211
let result = match request.method.as_str() {
183212
method::BROWSER_WATCH | method::BROWSER_UNWATCH => {
@@ -194,46 +223,67 @@ fn handle(
194223
| method::BROWSER_NEW_TAB
195224
| method::BROWSER_SWITCH_TAB
196225
| method::BROWSER_OPEN => watch.request(daemon, &request.method, request.params),
197-
name => dispatch::dispatch(daemon, name, request.params),
226+
name if ASIDE.contains(&name) => return Routed::Aside(request),
227+
_ => return Routed::InOrder(request),
198228
};
199-
Some(match result {
200-
Ok(value) => jsonrpc::success(&id, value),
201-
Err(err) => jsonrpc::failure(&id, &err),
229+
Routed::Reply(Some(reply(&id, result)))
230+
}
231+
232+
/// Answers on the blocking pool, so the request holds no runtime thread.
233+
/// The connection waits for it: the app takes an answer as newer than
234+
/// every notification before it (spec 11.1).
235+
async fn answer(daemon: &Arc<Daemon>, request: jsonrpc::Request) -> Option<String> {
236+
let daemon = Arc::clone(daemon);
237+
let id = request.id.clone().unwrap_or(Value::Null);
238+
let answered = tokio::task::spawn_blocking(move || {
239+
dispatch::dispatch(&daemon, &request.method, request.params)
202240
})
241+
.await;
242+
let result = answered.unwrap_or_else(|_| {
243+
Err(RpcError::new(
244+
error_code::INTERNAL_ERROR,
245+
"the request failed inside the daemon",
246+
))
247+
});
248+
Some(reply(&id, result))
203249
}
204250

205-
fn to_notification(event: &Event) -> String {
206-
let (name, params) = match event {
207-
Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)),
208-
Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)),
209-
Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)),
210-
Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)),
211-
Event::FolderRecycled(folder) => {
212-
(notification::FOLDER_RECYCLED, serde_json::to_value(folder))
213-
}
214-
Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)),
215-
Event::BotContext(context) => (notification::BOT_CONTEXT, serde_json::to_value(context)),
216-
Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)),
217-
Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)),
218-
Event::MessageCreated(message) => {
219-
(notification::MESSAGE_CREATED, serde_json::to_value(message))
220-
}
221-
Event::DeliveryChanged(delivery) => (
222-
notification::DELIVERY_CHANGED,
223-
serde_json::to_value(delivery),
224-
),
225-
Event::TaskChanged(task) => (notification::TASK_CHANGED, serde_json::to_value(task)),
226-
Event::RoutineChanged(routine) => {
227-
(notification::ROUTINE_CHANGED, serde_json::to_value(routine))
251+
/// Answers an [`ASIDE`] read on its own.
252+
fn aside(daemon: &Arc<Daemon>, request: jsonrpc::Request, outbox: mpsc::Sender<String>) {
253+
let daemon = Arc::clone(daemon);
254+
tokio::spawn(async move {
255+
if let Some(frame) = answer(&daemon, request).await {
256+
let _ = outbox.send(frame).await;
228257
}
229-
Event::RoutineRun(run) => (notification::ROUTINE_RUN, serde_json::to_value(run)),
230-
Event::BrowserChanged(state) => {
231-
(notification::BROWSER_CHANGED, serde_json::to_value(state))
232-
}
233-
Event::BrowserAction(action) => {
234-
(notification::BROWSER_ACTION, serde_json::to_value(action))
235-
}
236-
Event::ScreenDraft(draft) => (notification::SCREEN_DRAFT, serde_json::to_value(draft)),
237-
};
238-
jsonrpc::notification(name, params.unwrap_or(Value::Null))
258+
});
259+
}
260+
261+
fn reply(id: &Value, result: Result<Value, RpcError>) -> String {
262+
match result {
263+
Ok(value) => jsonrpc::success(id, value),
264+
Err(err) => jsonrpc::failure(id, &err),
265+
}
266+
}
267+
268+
fn to_notification(event: &Event) -> String {
269+
use jsonrpc::notification as note;
270+
match event {
271+
Event::CrewChanged(crew) => note(notification::CREW_CHANGED, crew),
272+
Event::CrewDeleted(crew) => note(notification::CREW_DELETED, crew),
273+
Event::BotChanged(bot) => note(notification::BOT_CHANGED, bot),
274+
Event::BotDeleted(bot) => note(notification::BOT_DELETED, bot),
275+
Event::FolderRecycled(folder) => note(notification::FOLDER_RECYCLED, folder),
276+
Event::BotState(state) => note(notification::BOT_STATE, state),
277+
Event::BotContext(context) => note(notification::BOT_CONTEXT, context),
278+
Event::ChatItem(item) => note(notification::CHAT_ITEM, item),
279+
Event::ChatDelta(delta) => note(notification::CHAT_DELTA, delta),
280+
Event::MessageCreated(message) => note(notification::MESSAGE_CREATED, message),
281+
Event::DeliveryChanged(delivery) => note(notification::DELIVERY_CHANGED, delivery),
282+
Event::TaskChanged(task) => note(notification::TASK_CHANGED, task),
283+
Event::RoutineChanged(routine) => note(notification::ROUTINE_CHANGED, routine),
284+
Event::RoutineRun(run) => note(notification::ROUTINE_RUN, run),
285+
Event::BrowserChanged(state) => note(notification::BROWSER_CHANGED, state),
286+
Event::BrowserAction(action) => note(notification::BROWSER_ACTION, action),
287+
Event::ScreenDraft(draft) => note(notification::SCREEN_DRAFT, draft),
288+
}
239289
}

‎crates/botloftd/src/rpc/watching.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,7 @@ impl Watch {
174174
return None;
175175
}
176176
let frame = watching.frames.borrow_and_update().clone()?;
177-
let params = serde_json::to_value(&*frame).unwrap_or(Value::Null);
178-
Some(jsonrpc::notification(notification::BROWSER_FRAME, params))
177+
Some(jsonrpc::notification(notification::BROWSER_FRAME, &*frame))
179178
}
180179
}
181180

‎crates/botloftd/tests/rpc_queue.rs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! Requests run off the runtime's threads (spec 11.1). An answer still
2+
//! goes out before the notifications that follow it, except for the reads
3+
//! the app never sets against notifications, which hold nothing back.
4+
5+
mod common;
6+
7+
use std::sync::Arc;
8+
use std::sync::mpsc;
9+
use std::time::Duration;
10+
11+
use common::bots::{Crew, two_bots};
12+
use common::stream;
13+
use serde_json::{Value, json};
14+
15+
/// Holds the store for a while on another thread, so the next request
16+
/// waits for it, then sends `request` and has the lead bot write text.
17+
/// Returns which went out first: the answer or the text.
18+
async fn race(c: &mut Crew, request: Value) -> Vec<&'static str> {
19+
// Both bots are ready and the courier has nothing to do.
20+
for _ in 0..2 {
21+
while c.app.notification("bot.state").await["state"] != "idle" {}
22+
}
23+
let (locked, is_locked) = mpsc::channel();
24+
let holder = std::thread::spawn({
25+
let daemon = Arc::clone(&c.t.daemon);
26+
move || {
27+
let _store = daemon.store();
28+
locked.send(()).expect("locked");
29+
std::thread::sleep(Duration::from_millis(1000));
30+
}
31+
});
32+
is_locked.recv().expect("store held");
33+
c.app.send_raw(&request.to_string()).await;
34+
// The block that ends sends the text at once, without the timer.
35+
let stop =
36+
json!({ "type": "stream_event", "event": { "type": "content_block_stop", "index": 0 } });
37+
c.lead_process
38+
.output(format!("{}\n{stop}\n", stream::delta("Still here.")).as_bytes())
39+
.await;
40+
41+
let mut order = Vec::new();
42+
while order.len() < 2 {
43+
let frame = c.app.recv().await.expect("open");
44+
if frame["id"] == request["id"] {
45+
assert!(frame.get("result").is_some(), "{frame}");
46+
order.push("answer");
47+
} else if frame["method"] == "chat.delta" {
48+
assert_eq!(frame["params"]["text"], "Still here.");
49+
order.push("text");
50+
}
51+
}
52+
holder.join().expect("holder");
53+
order
54+
}
55+
56+
#[tokio::test(flavor = "multi_thread", worker_threads = 16)]
57+
async fn a_slow_read_holds_back_no_notification() {
58+
let mut c = two_bots().await;
59+
let bots = c.app.call("bots.list", json!({})).await.expect("bots");
60+
let bot = bots[0]["id"].clone();
61+
let request =
62+
json!({ "jsonrpc": "2.0", "id": 900, "method": "files.list", "params": { "botId": bot } });
63+
assert_eq!(race(&mut c, request).await, ["text", "answer"]);
64+
}
65+
66+
#[tokio::test(flavor = "multi_thread", worker_threads = 16)]
67+
async fn an_answer_goes_out_before_the_notifications_after_it() {
68+
let mut c = two_bots().await;
69+
let request = json!({ "jsonrpc": "2.0", "id": 901, "method": "crews.list" });
70+
assert_eq!(race(&mut c, request).await, ["answer", "text"]);
71+
}
72+
73+
#[tokio::test]
74+
async fn requests_are_answered_in_the_order_they_came() {
75+
let mut c = two_bots().await;
76+
for id in 1000..1010 {
77+
let request = json!({ "jsonrpc": "2.0", "id": id, "method": "crews.list" });
78+
c.app.send_raw(&request.to_string()).await;
79+
}
80+
let mut ids = Vec::new();
81+
while ids.len() < 10 {
82+
let frame = c.app.recv().await.expect("open");
83+
if let Some(id) = frame.get("id").and_then(Value::as_u64) {
84+
ids.push(id);
85+
}
86+
}
87+
assert_eq!(ids, (1000..1010).collect::<Vec<_>>());
88+
}

‎docs/spec.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co
580580
- Primeiro request obrigatório: `session.hello {token, client: {name, version}, protocol: 2}` -> `{daemonVersion, protocol}`.
581581
- Qualquer outro método antes do hello: erro `-32001` e a conexão fecha. Token errado também dá `-32001`; `protocol` diferente dá `-32004`; nos dois casos a conexão fecha. O hello tem que chegar em até 10 s.
582582
- `Origin` aceito: `http://tauri.localhost`, `tauri://localhost` e `http://localhost:1420` (dev). Qualquer outro `Origin` recebe HTTP 403 antes do upgrade. Sem `Origin` (cliente nativo, testes) é aceito: navegadores sempre mandam o header, e o token continua obrigatório.
583-
- Um cliente lento que deixa acumular mais de 1024 notificações é desconectado e recarrega o estado ao reconectar.
583+
- Um cliente lento que deixa acumular mais de 4096 notificações é desconectado e recarrega o estado ao reconectar.
584+
- Os requests rodam numa thread de bloqueio, nunca numa do runtime. Os de uma conexão são respondidos um de cada vez, na ordem em que chegaram, e a resposta sai antes de qualquer notificação que venha depois dela: o app trata uma resposta como mais nova que tudo o que recebeu antes e grava o objeto inteiro (um `Bot`, uma `Routine`, a lista de uma carga). A exceção são as leituras que o app nunca cruza com notificações (`files.list`, `files.read`, `screens.list`, `attachments.read`, `usage.tokens`). Elas correm por fora e respondem quando ficam prontas, de modo que varrer uma pasta grande ou ler um arquivo grande não segura o texto ao vivo. Os requests do navegador (21.7, 21.10) pertencem à conexão e são respondidos na hora.
584585
- A versão 2 do protocolo troca `terminal.*` pelo chat (ADR 0001).
585586

586587
### 11.2 Métodos (MVP)

0 commit comments

Comments
 (0)