@@ -19,10 +19,6 @@ const TERMINAL_INPUT_MAX_BYTES: usize = 64 * 1024;
1919const TERMINAL_INPUT_MAX_BASE64_BYTES : usize = TERMINAL_INPUT_MAX_BYTES * 4 / 3 + 4 ;
2020const TERMINAL_SNAPSHOT_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
2121const 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 ) ;
2622const 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 ) ]
4680pub ( 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
0 commit comments