Skip to content

Commit 7f15f19

Browse files
fix(broker): serialize terminal worker writes
1 parent b6d7d36 commit 7f15f19

5 files changed

Lines changed: 496 additions & 167 deletions

File tree

crates/broker/src/runtime/api.rs

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -736,35 +736,22 @@ impl BrokerRuntime {
736736
timeout_ms,
737737
reply,
738738
} => {
739-
let Some(handle) = workers.workers.get_mut(&name) else {
739+
if !workers.workers.contains_key(&name) {
740740
let _ = reply.send(Err(format!("unknown worker '{}'", name)));
741741
return;
742-
};
742+
}
743743

744744
let model_command = format!("/model {}\n", model);
745-
let result = async {
746-
handle
747-
.stdin
748-
.write_all(model_command.as_bytes())
749-
.await
750-
.with_context(|| {
751-
format!("failed writing model command to worker '{}'", name)
752-
})?;
753-
handle
754-
.stdin
755-
.flush()
756-
.await
757-
.with_context(|| format!("failed flushing worker '{}' stdin", name))?;
758-
if let Some(timeout_ms) = timeout_ms {
759-
tracing::info!(
760-
name = %name,
761-
timeout_ms,
762-
"HTTP API set_model timeout_ms is currently advisory only"
763-
);
764-
}
765-
Ok::<(), anyhow::Error>(())
745+
let result = workers
746+
.send_raw_to_worker(&name, model_command.into_bytes())
747+
.await;
748+
if let Some(timeout_ms) = timeout_ms {
749+
tracing::info!(
750+
name = %name,
751+
timeout_ms,
752+
"HTTP API set_model timeout_ms is currently advisory only"
753+
);
766754
}
767-
.await;
768755

769756
match result {
770757
Ok(()) => {

crates/broker/src/runtime/fleet.rs

Lines changed: 130 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ const TERMINAL_INPUT_MAX_BYTES: usize = 64 * 1024;
1919
const TERMINAL_INPUT_MAX_BASE64_BYTES: usize = TERMINAL_INPUT_MAX_BYTES * 4 / 3 + 4;
2020
const TERMINAL_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
2121
const TERMINAL_INPUT_ACK_TIMEOUT: Duration = Duration::from_secs(5);
22-
// Terminal worker writes share the broker event loop with fleet control and
23-
// worker lifecycle events. A wedged PTY must fail only its own attach instead
24-
// of awaiting an unbounded pipe write in that loop.
25-
const TERMINAL_WORKER_WRITE_TIMEOUT: Duration = Duration::from_millis(250);
2622
const TERMINAL_INPUT_MAX_IN_FLIGHT_PER_SESSION: usize = 16;
2723
// Relaycast currently limits a node to 32 terminal sessions. Keep that many
2824
// slots free from high-volume frames so every affected session can still get a
@@ -42,6 +38,44 @@ pub(super) fn try_send_terminal(
4238
.is_ok()
4339
}
4440

41+
pub(super) fn fail_terminal_session(
42+
terminal_control_tx: &mpsc::Sender<TerminalControlCommand>,
43+
terminal_sessions: &mut HashMap<String, TerminalSession>,
44+
terminal_snapshot_requests: &mut HashMap<String, TerminalSnapshotRequest>,
45+
terminal_input_requests: &mut HashMap<String, TerminalInputRequest>,
46+
session_id: String,
47+
code: &str,
48+
message: String,
49+
) {
50+
terminal_sessions.remove(&session_id);
51+
terminal_snapshot_requests.retain(|_, pending| pending.session_id != session_id);
52+
terminal_input_requests.retain(|_, pending| pending.session_id != session_id);
53+
54+
// Error is useful when the terminal lane has room, but it is non-final and
55+
// deliberately gives way to the reserved close capacity. Queue its close
56+
// directly instead of routing through `send_terminal`: the generic
57+
// backpressure fallback would otherwise produce a second close with a
58+
// different reason.
59+
let _ = try_send_terminal(
60+
terminal_control_tx,
61+
TerminalToCloud::Error {
62+
session_id: session_id.clone(),
63+
code: code.into(),
64+
message: message.clone(),
65+
},
66+
);
67+
if !try_send_terminal(
68+
terminal_control_tx,
69+
TerminalToCloud::Closed {
70+
session_id: session_id.clone(),
71+
code: Some(code.into()),
72+
message: Some(message),
73+
},
74+
) {
75+
tracing::warn!(target = "relay_broker::terminal", session_id = %session_id, "terminal close could not be queued after session failure");
76+
}
77+
}
78+
4579
#[derive(Debug, Clone)]
4680
pub(super) struct PendingVerifiedSpawn {
4781
pub(super) invocation_id: String,
@@ -153,44 +187,29 @@ impl BrokerRuntime {
153187
pending_output_bytes: 0,
154188
},
155189
);
156-
// Request the grid asynchronously. The response is routed
157-
// from worker_events to the terminal lane, so this never
158-
// stalls heartbeat/action processing behind a PTY snapshot.
190+
// Queue the grid request on the worker-owned stdin
191+
// writer. The event loop never awaits a PTY pipe write;
192+
// the writer serializes complete frames and reports a
193+
// later pipe failure as a terminal session failure.
159194
let request_id = format!("terminal_snapshot_{}", Uuid::new_v4().simple());
160-
match tokio::time::timeout(
161-
TERMINAL_WORKER_WRITE_TIMEOUT,
162-
self.workers.send_to_worker(
163-
agent_name.as_str(),
164-
"snapshot_pty",
165-
Some(RequestId::new(request_id.clone())),
166-
json!({ "format": "ansi" }),
167-
),
168-
)
169-
.await
170-
{
171-
Ok(Ok(())) => {
172-
self.terminal_snapshot_requests.insert(
173-
request_id,
174-
TerminalSnapshotRequest {
175-
session_id,
176-
deadline: Instant::now() + TERMINAL_SNAPSHOT_TIMEOUT,
177-
},
178-
);
179-
}
180-
Ok(Err(error)) => {
181-
self.fail_terminal_session(
182-
session_id,
183-
"snapshot_failed",
184-
error.to_string(),
185-
);
186-
}
187-
Err(_) => {
188-
self.fail_terminal_session(
189-
session_id,
190-
"snapshot_timeout",
191-
"terminal snapshot write timed out".into(),
192-
);
193-
}
195+
self.terminal_snapshot_requests.insert(
196+
request_id.clone(),
197+
TerminalSnapshotRequest {
198+
session_id: session_id.clone(),
199+
deadline: Instant::now() + TERMINAL_SNAPSHOT_TIMEOUT,
200+
},
201+
);
202+
if let Err(error) = self.workers.try_send_to_worker(
203+
agent_name.as_str(),
204+
"snapshot_pty",
205+
Some(RequestId::new(request_id.clone())),
206+
json!({ "format": "ansi" }),
207+
) {
208+
self.fail_terminal_session(
209+
session_id,
210+
"snapshot_failed",
211+
error.to_string(),
212+
);
194213
}
195214
}
196215
}
@@ -268,34 +287,20 @@ impl BrokerRuntime {
268287
return;
269288
}
270289
let request_id = format!("terminal_input_{}", Uuid::new_v4().simple());
271-
match tokio::time::timeout(
272-
TERMINAL_WORKER_WRITE_TIMEOUT,
273-
self.workers.send_to_worker(
274-
session.agent.as_str(),
275-
"write_pty",
276-
Some(RequestId::new(request_id.clone())),
277-
json!({ "data": data }),
278-
),
279-
)
280-
.await
281-
{
282-
Ok(Ok(())) => {
283-
self.terminal_input_requests.insert(
284-
request_id,
285-
TerminalInputRequest {
286-
session_id,
287-
deadline: Instant::now() + TERMINAL_INPUT_ACK_TIMEOUT,
288-
},
289-
);
290-
}
291-
Ok(Err(error)) => {
292-
self.fail_terminal_session(session_id, "input_failed", error.to_string())
293-
}
294-
Err(_) => self.fail_terminal_session(
295-
session_id,
296-
"input_timeout",
297-
"terminal input write timed out".into(),
298-
),
290+
self.terminal_input_requests.insert(
291+
request_id.clone(),
292+
TerminalInputRequest {
293+
session_id: session_id.clone(),
294+
deadline: Instant::now() + TERMINAL_INPUT_ACK_TIMEOUT,
295+
},
296+
);
297+
if let Err(error) = self.workers.try_send_to_worker(
298+
session.agent.as_str(),
299+
"write_pty",
300+
Some(RequestId::new(request_id.clone())),
301+
json!({ "data": data }),
302+
) {
303+
self.fail_terminal_session(session_id, "input_failed", error.to_string());
299304
}
300305
}
301306
TerminalControlEvent::Message(TerminalFromCloud::Resize {
@@ -327,26 +332,13 @@ impl BrokerRuntime {
327332
});
328333
return;
329334
}
330-
match tokio::time::timeout(
331-
TERMINAL_WORKER_WRITE_TIMEOUT,
332-
self.workers.send_to_worker(
333-
session.agent.as_str(),
334-
"resize_pty",
335-
None,
336-
json!({ "rows": rows, "cols": cols }),
337-
),
338-
)
339-
.await
340-
{
341-
Ok(Ok(())) => {}
342-
Ok(Err(error)) => {
343-
self.fail_terminal_session(session_id, "resize_failed", error.to_string())
344-
}
345-
Err(_) => self.fail_terminal_session(
346-
session_id,
347-
"resize_timeout",
348-
"terminal resize write timed out".into(),
349-
),
335+
if let Err(error) = self.workers.try_send_to_worker(
336+
session.agent.as_str(),
337+
"resize_pty",
338+
None,
339+
json!({ "rows": rows, "cols": cols }),
340+
) {
341+
self.fail_terminal_session(session_id, "resize_failed", error.to_string());
350342
}
351343
}
352344
TerminalControlEvent::Message(TerminalFromCloud::Close { session_id }) => {
@@ -399,21 +391,15 @@ impl BrokerRuntime {
399391
}
400392

401393
fn fail_terminal_session(&mut self, session_id: String, code: &str, message: String) {
402-
self.terminal_sessions.remove(&session_id);
403-
self.terminal_snapshot_requests
404-
.retain(|_, pending| pending.session_id != session_id);
405-
self.terminal_input_requests
406-
.retain(|_, pending| pending.session_id != session_id);
407-
self.send_terminal(TerminalToCloud::Error {
408-
session_id: session_id.clone(),
409-
code: code.into(),
410-
message: message.clone(),
411-
});
412-
self.send_terminal(TerminalToCloud::Closed {
394+
fail_terminal_session(
395+
&self.terminal_control_tx,
396+
&mut self.terminal_sessions,
397+
&mut self.terminal_snapshot_requests,
398+
&mut self.terminal_input_requests,
413399
session_id,
414-
code: Some(code.into()),
415-
message: Some(message),
416-
});
400+
code,
401+
message,
402+
);
417403
}
418404

419405
pub(super) async fn handle_fleet_control_event(&mut self, event: FleetControlEvent) {
@@ -1701,6 +1687,47 @@ mod tests {
17011687
));
17021688
}
17031689

1690+
#[test]
1691+
fn terminal_failure_queues_one_close_with_the_original_reason_at_reserve() {
1692+
let (tx, mut rx) = mpsc::channel(TERMINAL_CLOSE_RESERVE + 1);
1693+
assert!(try_send_terminal(
1694+
&tx,
1695+
TerminalToCloud::Output {
1696+
session_id: "session-a".into(),
1697+
chunk: "x".into(),
1698+
offset: None,
1699+
},
1700+
));
1701+
// This leaves exactly the reserved close capacity. The non-final Error
1702+
// must be rejected, but the single final close must still carry the
1703+
// actual failure rather than an output_backpressure fallback.
1704+
fail_terminal_session(
1705+
&tx,
1706+
&mut HashMap::new(),
1707+
&mut HashMap::new(),
1708+
&mut HashMap::new(),
1709+
"session-a".into(),
1710+
"snapshot_failed",
1711+
"worker command queue is full".into(),
1712+
);
1713+
1714+
assert!(matches!(
1715+
rx.try_recv(),
1716+
Ok(TerminalControlCommand::Send(TerminalToCloud::Output { .. }))
1717+
));
1718+
assert!(matches!(
1719+
rx.try_recv(),
1720+
Ok(TerminalControlCommand::Send(TerminalToCloud::Closed {
1721+
session_id,
1722+
code: Some(code),
1723+
message: Some(message),
1724+
})) if session_id == "session-a"
1725+
&& code == "snapshot_failed"
1726+
&& message == "worker command queue is full"
1727+
));
1728+
assert!(rx.try_recv().is_err(), "failure must emit only one close");
1729+
}
1730+
17041731
#[test]
17051732
fn classify_fleet_delivery_injects_message_classes_and_acks_receipts() {
17061733
// Mirrors relaycast parse_inbound_kind message-class alias set: any of

crates/broker/src/runtime/tests.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ use crate::protocol::{
1717
HeadlessHarnessDriver, MessageInjectionMode, NativeHarnessConfig, RelayDelivery,
1818
ResolvedHarnessConfig,
1919
};
20-
use crate::worker::{AgentWorkState, WorkerEvent, WorkerHandle, WorkerRegistry};
20+
use crate::worker::{
21+
spawn_worker_writer, AgentWorkState, WorkerEvent, WorkerHandle, WorkerRegistry,
22+
};
2123
use crate::{
2224
broker::injection_format::format_injection,
2325
util::{
@@ -71,7 +73,7 @@ fn env_test_lock() -> &'static Mutex<()> {
7173
async fn make_worker_registry_with_worker(name: &str) -> WorkerRegistry {
7274
let (tx, _rx) = mpsc::channel::<WorkerEvent>(16);
7375
let mut registry = WorkerRegistry::new(
74-
tx,
76+
tx.clone(),
7577
Vec::new(),
7678
PathBuf::from("/tmp/agent-relay-broker-tests"),
7779
Instant::now(),
@@ -83,10 +85,13 @@ async fn make_worker_registry_with_worker(name: &str) -> WorkerRegistry {
8385
.spawn()
8486
.expect("test worker process should spawn");
8587
let stdin = child.stdin.take().expect("test worker stdin should exist");
88+
let generation = Uuid::new_v4();
89+
let (command_tx, command_rx) = mpsc::channel(128);
90+
spawn_worker_writer(tx, WorkerName::from(name), generation, stdin, command_rx);
8691
registry.workers.insert(
8792
WorkerName::from(name),
8893
WorkerHandle {
89-
generation: Uuid::new_v4(),
94+
generation,
9095
spec: AgentSpec {
9196
name: WorkerName::from(name),
9297
runtime: AgentRuntime::Pty,
@@ -106,7 +111,7 @@ async fn make_worker_registry_with_worker(name: &str) -> WorkerRegistry {
106111
parent: None,
107112
workspace_id: Some(WorkspaceId::new("ws_demo")),
108113
child,
109-
stdin,
114+
command_tx,
110115
harness_pid: None,
111116
spawned_at: Instant::now(),
112117
// Ready, so the orphan sweep's readiness deadline never applies to

0 commit comments

Comments
 (0)