diff --git a/crates/cli/src/learn.md b/crates/cli/src/learn.md index 68b574e9..e4c1ede0 100644 --- a/crates/cli/src/learn.md +++ b/crates/cli/src/learn.md @@ -162,6 +162,9 @@ yas terminal attach "$ID" # drive it from here; Ctrl-] detaches yas quit # shut down the server ``` +A disconnected CLI exits with `yas: disconnected by the YAS server: REASON` +when the disconnecting client gave a reason. + Terminals persist until closed or the daemon exits. Clean up when done. `attach` needs a real tty on stdin and repaints the remote grid in the diff --git a/crates/cli/src/yas_native.rs b/crates/cli/src/yas_native.rs index 75aef454..3f6a935b 100644 --- a/crates/cli/src/yas_native.rs +++ b/crates/cli/src/yas_native.rs @@ -989,11 +989,7 @@ impl NativeClient { && frame.header.kind == yas_wire::core::event_kind::GOAWAY { let goaway = GoAway::decode(&frame.payload).map_err(wire_error)?; - return Err(format!( - "YAS server is closing with {:?}: {}", - goaway.status, - format_result_detail(&goaway.detail) - )); + return Err(goaway_message(&goaway)); } if frame.header.class == Class::Event && frame.header.family == family::CORE @@ -1198,11 +1194,7 @@ impl NativeFrameReader { && frame.header.kind == yas_wire::core::event_kind::GOAWAY { let goaway = GoAway::decode(&frame.payload).map_err(wire_error)?; - return Err(format!( - "YAS server is closing with {:?}: {}", - goaway.status, - format_result_detail(&goaway.detail) - )); + return Err(goaway_message(&goaway)); } if frame.header.class == Class::Event && frame.header.family == family::CORE @@ -1388,6 +1380,17 @@ fn wire_error(error: yas_wire::Error) -> String { format!("YAS wire error: {error}") } +fn goaway_message(goaway: &GoAway) -> String { + match goaway.reason() { + Some(reason) => format!("disconnected by the YAS server: {reason}"), + None => format!( + "YAS server is closing with {:?}: {}", + goaway.status, + format_result_detail(&goaway.detail) + ), + } +} + pub(crate) fn format_result_detail(detail: &Extensions) -> String { if detail.0.is_empty() { "no detail".to_string() @@ -1411,6 +1414,27 @@ pub(crate) fn format_result_detail(detail: &Extensions) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn goaway_with_a_disconnect_reason_names_it() { + let kicked = GoAway { + status: yas_wire::core::Status::Ok, + close_deadline_server_ns: 0, + detail: GoAway::reason_detail("Removed by an administrator"), + }; + assert_eq!( + goaway_message(&kicked), + "disconnected by the YAS server: Removed by an administrator" + ); + let closing = GoAway { + detail: Extensions::default(), + ..kicked + }; + assert_eq!( + goaway_message(&closing), + "YAS server is closing with Ok: no detail" + ); + } use yas_wire::core::{FamilyDescriptor, Operation, RuntimeState, SessionUpdate}; fn test_server_hello() -> ServerHello { diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 091d2fa8..4953faab 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -956,7 +956,7 @@ struct NativeYasClient { identity: NativeClientIdentity, origin: ConnectionOrigin, connected_at: Instant, - disconnect: mpsc::Sender<()>, + disconnect: mpsc::Sender, inbound_bytes: Arc, outbound_bytes: Arc, active_subscriptions: Arc, diff --git a/crates/server/src/yas.rs b/crates/server/src/yas.rs index 0bbc9285..7def6b30 100644 --- a/crates/server/src/yas.rs +++ b/crates/server/src/yas.rs @@ -2052,7 +2052,7 @@ async fn serve_registered( ) }); let native_active_subscriptions = Arc::new(NativeYasSubscriptions::default()); - let (native_disconnect_tx, mut native_disconnect_rx) = mpsc::channel(1); + let (native_disconnect_tx, mut native_disconnect_rx) = mpsc::channel::(1); let native_client_registered = registration.is_some() && services.app_state.is_some(); #[cfg(target_os = "linux")] let mut native_backend_owner = None; @@ -2610,10 +2610,10 @@ async fn serve_registered( None => break 'session "native Channel terminal event channel closed".to_owned(), }, disconnect = native_disconnect_rx.recv(), if native_client_registered => { - if disconnect.is_none() { + let Some(reason) = disconnect else { break 'session "native client disconnect channel closed".to_owned(); - } - if session.send_client_goaway().await.is_err() { + }; + if session.send_client_goaway(&reason).await.is_err() { break 'session "native client disconnect requested; GOAWAY failed".to_owned(); } break 'session "native client disconnect requested; GOAWAY sent".to_owned(); @@ -7301,11 +7301,11 @@ impl Session { Ok(()) } - async fn send_client_goaway(&self) -> Result<(), ()> { + async fn send_client_goaway(&self, reason: &str) -> Result<(), ()> { let payload = GoAway { status: Status::Ok, close_deadline_server_ns: monotonic_ns(), - detail: Extensions::default(), + detail: GoAway::reason_detail(reason), } .encode() .map_err(|_| ())?; @@ -8675,7 +8675,7 @@ impl Session { self.send_sensitive_result_confirmed(&frame, status, Vec::new()) .await?; if let Some(disconnect) = target { - let _ = disconnect.send(()).await; + let _ = disconnect.send(request.reason).await; } Ok(()) } diff --git a/crates/yas/src/core.rs b/crates/yas/src/core.rs index 0a6f1d93..6788b425 100644 --- a/crates/yas/src/core.rs +++ b/crates/yas/src/core.rs @@ -1524,6 +1524,30 @@ pub struct GoAway { pub detail: Extensions, } +impl GoAway { + /// Detail carrying the UTF-8 reason a Client DISCONNECT gave; empty for + /// an empty reason. + pub fn reason_detail(reason: &str) -> Extensions { + if reason.is_empty() { + return Extensions::default(); + } + Extensions(vec![Extension { + tag: crate::schema::core::GOAWAY_REASON_EXTENSION as u16, + required: false, + value: reason.as_bytes().to_vec(), + }]) + } + + /// The disconnect reason in `detail`, if the server sent one. + pub fn reason(&self) -> Option { + self.detail + .0 + .iter() + .find(|extension| extension.tag == crate::schema::core::GOAWAY_REASON_EXTENSION as u16) + .map(|extension| String::from_utf8_lossy(&extension.value).into_owned()) + } +} + impl Encode for GoAway { fn encode_to(&self, out: &mut Vec) -> Result<()> { validate_result_or_event_extensions(&self.detail, "required GOAWAY detail extension")?; @@ -1678,6 +1702,26 @@ impl Decode for FamilyUpdate { mod tests { use super::*; + #[test] + fn goaway_reason_round_trips_as_optional_detail() { + let goaway = GoAway { + status: Status::Ok, + close_deadline_server_ns: 7, + detail: GoAway::reason_detail("Removed by an administrator"), + }; + let decoded = GoAway::decode(&goaway.encode().unwrap()).unwrap(); + assert_eq!( + decoded.reason().as_deref(), + Some("Removed by an administrator") + ); + assert!(GoAway::reason_detail("").0.is_empty()); + let silent = GoAway { + detail: Extensions::default(), + ..goaway + }; + assert_eq!(silent.reason(), None); + } + fn limits() -> ReceiveLimits { ReceiveLimits { max_frame: 1024, diff --git a/docs/design/yas.md b/docs/design/yas.md index 0dac3132..3eb5c645 100644 --- a/docs/design/yas.md +++ b/docs/design/yas.md @@ -2139,7 +2139,9 @@ present in a selected family descriptor and cannot exceed their canonical hard maximums. DISCONNECT names a session ID, operation ID, and UTF-8 reason. Its Result is -queued before the target receives Core GOAWAY. Targeting the caller is valid +queued before the target receives Core GOAWAY. A nonempty reason travels in +that GOAWAY's detail as optional Core extension `GOAWAY_REASON_EXTENSION` (1), +whose value is the reason's UTF-8 bytes. Targeting the caller is valid and becomes an orderly self-disconnect after the Result is sent. Core SHUTDOWN, not this family, stops the whole server. diff --git a/js/core/src/yas/generated.ts b/js/core/src/yas/generated.ts index ebd56bf0..a8349eea 100644 --- a/js/core/src/yas/generated.ts +++ b/js/core/src/yas/generated.ts @@ -209,6 +209,7 @@ export const YAS_CORE_EXTENSION_SUPPORT_WASMI = 2 as const; export const YAS_CORE_EXTENSION_SUPPORT_QUICKJS = 4 as const; export const YAS_CORE_EXTENSION_SUPPORT_COMMAND_PROVIDER = 8 as const; export const YAS_CORE_SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION = 1 as const; +export const YAS_CORE_GOAWAY_REASON_EXTENSION = 1 as const; export const YAS_FAMILY_TRANSFER = 1 as const; export const YAS_TRANSFER_VERSION = 1 as const; export const YAS_TRANSFER_BYTE_DATA = 0 as const; @@ -3214,6 +3215,10 @@ export const YAS_SCHEMA = { { "name": "SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION", "value": 1 + }, + { + "name": "GOAWAY_REASON_EXTENSION", + "value": 1 } ] }, diff --git a/protocol/yas/families/core.toml b/protocol/yas/families/core.toml index bd414ad8..1b9d98b8 100644 --- a/protocol/yas/families/core.toml +++ b/protocol/yas/families/core.toml @@ -73,6 +73,10 @@ value = 8 name = "SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION" value = 1 +[[constant]] +name = "GOAWAY_REASON_EXTENSION" +value = 1 + [[request]] name = "HELLO" kind = 0x0000 diff --git a/protocol/yas/generated.rs b/protocol/yas/generated.rs index b6bb74e9..85b62bc3 100644 --- a/protocol/yas/generated.rs +++ b/protocol/yas/generated.rs @@ -358,6 +358,7 @@ pub const EXTENSION_SUPPORT_WASMI: u64 = 2; pub const EXTENSION_SUPPORT_QUICKJS: u64 = 4; pub const EXTENSION_SUPPORT_COMMAND_PROVIDER: u64 = 8; pub const SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION: u64 = 1; +pub const GOAWAY_REASON_EXTENSION: u64 = 1; pub static OPERATIONS: &[super::OperationMetadata] = &[ super::OperationMetadata { name: "HELLO", class: 1, kind: 0, direction: 0, sensitive: 0, compression: 2, datagram: 0, layout: "ClientHello; ResultPrefix + ServerHello" }, super::OperationMetadata { name: "PING", class: 1, kind: 1, direction: 2, sensitive: 0, compression: 0, datagram: 0, layout: "sender_monotonic_ns:u64; ResultPrefix + receiver_receive_ns:u64,receiver_send_ns:u64" }, @@ -406,6 +407,7 @@ super::ConstantMetadata { name: "EXTENSION_SUPPORT_WASMI", value: 2 }, super::ConstantMetadata { name: "EXTENSION_SUPPORT_QUICKJS", value: 4 }, super::ConstantMetadata { name: "EXTENSION_SUPPORT_COMMAND_PROVIDER", value: 8 }, super::ConstantMetadata { name: "SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION", value: 1 }, +super::ConstantMetadata { name: "GOAWAY_REASON_EXTENSION", value: 1 }, ]; } pub mod transfer { diff --git a/protocol/yas/schema.json b/protocol/yas/schema.json index 3218711f..a98ac742 100644 --- a/protocol/yas/schema.json +++ b/protocol/yas/schema.json @@ -347,6 +347,10 @@ { "name": "SESSION_INFO_SERVER_DIAGNOSTICS_EXTENSION", "value": 1 + }, + { + "name": "GOAWAY_REASON_EXTENSION", + "value": 1 } ] },