diff --git a/crates/pet-jsonrpc/src/framing.rs b/crates/pet-jsonrpc/src/framing.rs new file mode 100644 index 00000000..37b9de2b --- /dev/null +++ b/crates/pet-jsonrpc/src/framing.rs @@ -0,0 +1,507 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::io::{self, BufRead, ErrorKind}; + +/// Maximum number of raw bytes in a frame's headers, including line endings +/// and the blank line separating the headers from the payload. +pub const MAX_HEADER_BYTES: usize = 8 * 1024; + +/// Maximum accepted payload size. The limit is checked before allocating. +pub const MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; + +/// Reads one Content-Length-framed message without decoding its payload. +/// +/// Both CRLF and LF line endings are accepted. EOF is clean only before any +/// bytes of the next frame have been read. +pub fn read_frame(reader: &mut R) -> io::Result>> { + let mut header_bytes = 0; + let mut line = Vec::new(); + let mut content_length = None; + + loop { + line.clear(); + let found_line_ending = read_bounded_line( + reader, + &mut line, + MAX_HEADER_BYTES.saturating_sub(header_bytes), + )?; + + if line.is_empty() && !found_line_ending { + return if header_bytes == 0 { + Ok(None) + } else { + Err(unexpected_eof("EOF while reading frame headers")) + }; + } + if !found_line_ending { + return Err(unexpected_eof("EOF while reading frame headers")); + } + + header_bytes = header_bytes + .checked_add(line.len()) + .ok_or_else(|| invalid_data("frame headers exceed the byte limit"))?; + + let header = strip_line_ending(&line)?; + if header.is_empty() { + break; + } + + let (name, value) = parse_header(header)?; + if name.eq_ignore_ascii_case(b"Content-Length") { + if content_length.is_some() { + return Err(invalid_data("duplicate Content-Length header")); + } + content_length = Some(parse_content_length(trim_ascii_whitespace(value))?); + } + } + + let content_length = + content_length.ok_or_else(|| invalid_data("missing Content-Length header"))?; + if content_length > MAX_PAYLOAD_BYTES { + return Err(invalid_data("frame payload exceeds the byte limit")); + } + + let mut payload = Vec::new(); + payload + .try_reserve_exact(content_length) + .map_err(|error| io::Error::other(format!("failed to allocate frame payload: {error}")))?; + payload.resize(content_length, 0); + reader + .read_exact(&mut payload) + .map_err(|error| match error.kind() { + ErrorKind::UnexpectedEof => unexpected_eof("EOF while reading frame payload"), + _ => error, + })?; + Ok(Some(payload)) +} + +fn read_bounded_line( + reader: &mut R, + line: &mut Vec, + byte_limit: usize, +) -> io::Result { + loop { + let buffer = loop { + match reader.fill_buf() { + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + result => break result?, + } + }; + if buffer.is_empty() { + return Ok(false); + } + + let bytes_to_take = buffer + .iter() + .position(|byte| *byte == b'\n') + .map_or(buffer.len(), |index| index + 1); + let remaining = byte_limit.saturating_sub(line.len()); + if bytes_to_take > remaining { + return Err(invalid_data("frame headers exceed the byte limit")); + } + + line.extend_from_slice(&buffer[..bytes_to_take]); + let found_line_ending = buffer[bytes_to_take - 1] == b'\n'; + reader.consume(bytes_to_take); + if found_line_ending { + return Ok(true); + } + } +} + +fn strip_line_ending(line: &[u8]) -> io::Result<&[u8]> { + let without_lf = line + .strip_suffix(b"\n") + .ok_or_else(|| invalid_data("header line is not terminated"))?; + Ok(without_lf.strip_suffix(b"\r").unwrap_or(without_lf)) +} + +fn parse_header(header: &[u8]) -> io::Result<(&[u8], &[u8])> { + let colon = header + .iter() + .position(|byte| *byte == b':') + .ok_or_else(|| invalid_data("malformed header"))?; + let name = &header[..colon]; + let value = &header[colon + 1..]; + + if name.is_empty() || !name.iter().all(|byte| is_header_name_byte(*byte)) { + return Err(invalid_data("malformed header name")); + } + if !value + .iter() + .all(|byte| *byte == b'\t' || (b' '..=b'~').contains(byte)) + { + return Err(invalid_data("malformed header value")); + } + + Ok((name, value)) +} + +fn is_header_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn trim_ascii_whitespace(mut value: &[u8]) -> &[u8] { + while matches!(value.first(), Some(b' ' | b'\t')) { + value = &value[1..]; + } + while matches!(value.last(), Some(b' ' | b'\t')) { + value = &value[..value.len() - 1]; + } + value +} + +fn parse_content_length(value: &[u8]) -> io::Result { + if value.is_empty() || !value.iter().all(u8::is_ascii_digit) { + return Err(invalid_data( + "Content-Length must be a non-negative decimal integer", + )); + } + + value.iter().try_fold(0usize, |length, byte| { + length + .checked_mul(10) + .and_then(|length| length.checked_add(usize::from(*byte - b'0'))) + .ok_or_else(|| invalid_data("Content-Length overflows usize")) + }) +} + +fn invalid_data(message: &'static str) -> io::Error { + io::Error::new(ErrorKind::InvalidData, message) +} + +fn unexpected_eof(message: &'static str) -> io::Error { + io::Error::new(ErrorKind::UnexpectedEof, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp; + use std::io::{BufReader, Cursor, Read}; + + fn frame_with_line_ending(payload: &[u8], line_ending: &[u8]) -> Vec { + let mut frame = format!("Content-Length: {}", payload.len()).into_bytes(); + frame.extend_from_slice(line_ending); + frame.extend_from_slice(line_ending); + frame.extend_from_slice(payload); + frame + } + + fn error_kind(input: &[u8]) -> ErrorKind { + read_frame(&mut Cursor::new(input)) + .expect_err("frame should be rejected") + .kind() + } + + #[test] + fn accepts_multiple_headers_in_any_order_and_case() { + let input = b"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\ + X-Extension: value\r\n\ + cOnTeNt-LeNgTh: 7\r\n\r\n\ + {\"x\":1}"; + assert_eq!( + read_frame(&mut Cursor::new(input)).unwrap(), + Some(br#"{"x":1}"#.to_vec()) + ); + + let input = b"X-Before: yes\nContent-Length: 2\nX-After: yes\n\n{}"; + assert_eq!( + read_frame(&mut Cursor::new(input)).unwrap(), + Some(b"{}".to_vec()) + ); + } + + #[test] + fn reads_consecutive_crlf_and_lf_frames() { + let mut input = frame_with_line_ending(b"one", b"\r\n"); + input.extend(frame_with_line_ending(b"two", b"\n")); + let mut reader = Cursor::new(input); + + assert_eq!(read_frame(&mut reader).unwrap(), Some(b"one".to_vec())); + assert_eq!(read_frame(&mut reader).unwrap(), Some(b"two".to_vec())); + assert_eq!(read_frame(&mut reader).unwrap(), None); + } + + #[test] + fn preserves_unicode_payload_bytes() { + let payload = "snowman: \u{2603}; crab: \u{1f980}".as_bytes(); + let input = frame_with_line_ending(payload, b"\r\n"); + assert_eq!( + read_frame(&mut Cursor::new(input)).unwrap(), + Some(payload.to_vec()) + ); + } + + #[test] + fn supports_zero_length_payload() { + let mut reader = Cursor::new(b"Content-Length: 0\r\n\r\nnext"); + assert_eq!(read_frame(&mut reader).unwrap(), Some(Vec::new())); + assert_eq!(reader.position(), 21); + } + + #[test] + fn distinguishes_clean_and_truncated_eof() { + assert_eq!(read_frame(&mut Cursor::new(b"")).unwrap(), None); + assert_eq!(error_kind(b"Content-Length: 1"), ErrorKind::UnexpectedEof); + assert_eq!(error_kind(b"Content-Length: 1\n"), ErrorKind::UnexpectedEof); + assert_eq!( + error_kind(b"Content-Length: 3\n\nab"), + ErrorKind::UnexpectedEof + ); + } + + #[test] + fn rejects_invalid_content_lengths() { + for input in [ + b"Content-Length:\n\n".as_slice(), + b"Content-Length: -1\n\n", + b"Content-Length: +1\n\n", + b"Content-Length: 1.0\n\n", + b"Content-Length: 1 0\n\n", + b"Content-Length: \xff\n\n", + ] { + assert_eq!(error_kind(input), ErrorKind::InvalidData, "{input:?}"); + } + + let overflow = format!("Content-Length: {}0\n\n", usize::MAX); + assert_eq!(error_kind(overflow.as_bytes()), ErrorKind::InvalidData); + let over_limit = format!("Content-Length: {}\n\n", MAX_PAYLOAD_BYTES + 1); + assert_eq!(error_kind(over_limit.as_bytes()), ErrorKind::InvalidData); + } + + #[test] + fn rejects_duplicate_and_missing_content_length() { + assert_eq!( + error_kind(b"Content-Length: 0\ncontent-length: 0\n\n"), + ErrorKind::InvalidData + ); + assert_eq!( + error_kind(b"Content-Type: application/json\n\n"), + ErrorKind::InvalidData + ); + } + + #[test] + fn rejects_malformed_headers() { + for input in [ + b"Content Length: 0\n\n".as_slice(), + b"Content-Length 0\n\n", + b": value\nContent-Length: 0\n\n", + b"X: value\rcontinued\nContent-Length: 0\n\n", + b"X: \x7f\nContent-Length: 0\n\n", + ] { + assert_eq!(error_kind(input), ErrorKind::InvalidData, "{input:?}"); + } + } + + fn header_of_size(size: usize) -> Vec { + let fixed = b"X: \nContent-Length: 0\n\n"; + assert!(size >= fixed.len()); + let mut header = b"X: ".to_vec(); + header.resize(size - (fixed.len() - 3), b'a'); + header.extend_from_slice(b"\nContent-Length: 0\n\n"); + assert_eq!(header.len(), size); + header + } + + #[test] + fn enforces_exact_header_byte_limit() { + let at_limit = header_of_size(MAX_HEADER_BYTES); + assert_eq!( + read_frame(&mut Cursor::new(at_limit)).unwrap(), + Some(Vec::new()) + ); + + let over_limit = header_of_size(MAX_HEADER_BYTES + 1); + assert_eq!(error_kind(&over_limit), ErrorKind::InvalidData); + } + + #[test] + fn accepts_exact_payload_limit_without_consuming_the_next_frame() { + let header = format!("Content-Length: {MAX_PAYLOAD_BYTES}\r\n\r\n"); + let input = Cursor::new(header.into_bytes()) + .chain(io::repeat(b'x').take(MAX_PAYLOAD_BYTES as u64)) + .chain(Cursor::new(b"Content-Length: 0\n\n")); + let mut reader = BufReader::new(input); + let payload = read_frame(&mut reader).unwrap().unwrap(); + assert_eq!(payload.len(), MAX_PAYLOAD_BYTES); + assert!(payload.iter().all(|byte| *byte == b'x')); + assert_eq!(read_frame(&mut reader).unwrap(), Some(Vec::new())); + assert_eq!(read_frame(&mut reader).unwrap(), None); + } + + #[test] + fn bounds_unterminated_header_lines() { + assert_eq!( + error_kind(&vec![b'a'; MAX_HEADER_BYTES]), + ErrorKind::UnexpectedEof + ); + assert_eq!( + error_kind(&vec![b'a'; MAX_HEADER_BYTES + 1]), + ErrorKind::InvalidData + ); + } + + struct FragmentedReader { + data: Vec, + position: usize, + chunks: Vec, + next_chunk: usize, + } + + impl FragmentedReader { + fn new(data: Vec, chunks: Vec) -> Self { + Self { + data, + position: 0, + chunks, + next_chunk: 0, + } + } + } + + impl Read for FragmentedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.position == self.data.len() { + return Ok(0); + } + let chunk = self.chunks[self.next_chunk % self.chunks.len()]; + self.next_chunk += 1; + let length = cmp::min( + chunk, + cmp::min(buffer.len(), self.data.len() - self.position), + ); + buffer[..length].copy_from_slice(&self.data[self.position..self.position + length]); + self.position += length; + Ok(length) + } + } + + #[test] + fn handles_deterministic_fragmentation_patterns() { + let payloads = [b"".as_slice(), b"x", br#"{"unicode":"\u2603","value":42}"#]; + let patterns = [ + vec![1], + vec![2, 1, 3], + vec![7, 4, 1, 9, 2], + (1..=17).collect(), + ]; + + for payload in payloads { + let frame = frame_with_line_ending(payload, b"\r\n"); + for chunks in &patterns { + let fragmented = FragmentedReader::new(frame.clone(), chunks.clone()); + let mut reader = BufReader::with_capacity(5, fragmented); + assert_eq!( + read_frame(&mut reader).unwrap(), + Some(payload.to_vec()), + "payload {payload:?}, chunks {chunks:?}" + ); + assert_eq!(read_frame(&mut reader).unwrap(), None); + } + } + + for seed in 0..32u32 { + let mut state = seed.wrapping_add(1); + let payload_length = (seed as usize * 37) % 257; + let payload: Vec = (0..payload_length) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + state.to_le_bytes()[2] + }) + .collect(); + let chunks: Vec = (0..23) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + usize::from(state.to_le_bytes()[1] % 19) + 1 + }) + .collect(); + let line_ending: &[u8] = if seed % 2 == 0 { b"\r\n" } else { b"\n" }; + let frame = frame_with_line_ending(&payload, line_ending); + let fragmented = FragmentedReader::new(frame, chunks); + let mut reader = BufReader::with_capacity((seed as usize % 11) + 1, fragmented); + + assert_eq!(read_frame(&mut reader).unwrap(), Some(payload)); + assert_eq!(read_frame(&mut reader).unwrap(), None); + } + } + + struct InterruptingReader { + inner: Cursor>, + interrupt_next: bool, + } + + impl Read for InterruptingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.interrupt_next { + self.interrupt_next = false; + return Err(io::Error::new(ErrorKind::Interrupted, "interrupted")); + } + self.interrupt_next = true; + self.inner.read(buffer) + } + } + + #[test] + fn retries_interrupted_header_and_payload_reads() { + let input = frame_with_line_ending(b"payload", b"\r\n"); + let interrupting = InterruptingReader { + inner: Cursor::new(input), + interrupt_next: true, + }; + let mut reader = BufReader::with_capacity(3, interrupting); + assert_eq!(read_frame(&mut reader).unwrap(), Some(b"payload".to_vec())); + } + + struct ErrorAfterReader { + inner: Cursor>, + bytes_before_error: usize, + } + + impl Read for ErrorAfterReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.bytes_before_error == 0 { + return Err(io::Error::other("reader failed")); + } + let limit = cmp::min(buffer.len(), self.bytes_before_error); + let read = self.inner.read(&mut buffer[..limit])?; + self.bytes_before_error -= read; + Ok(read) + } + } + + #[test] + fn propagates_actual_reader_errors_in_headers_and_payload() { + for bytes_before_error in [5, b"Content-Length: 4\r\n\r\n".len() + 2] { + let input = frame_with_line_ending(b"data", b"\r\n"); + let failing = ErrorAfterReader { + inner: Cursor::new(input), + bytes_before_error, + }; + let mut reader = BufReader::with_capacity(3, failing); + assert_eq!( + read_frame(&mut reader).unwrap_err().kind(), + ErrorKind::Other + ); + } + } +} diff --git a/crates/pet-jsonrpc/src/lib.rs b/crates/pet-jsonrpc/src/lib.rs index 8a3341e8..99eb0705 100644 --- a/crates/pet-jsonrpc/src/lib.rs +++ b/crates/pet-jsonrpc/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; +mod framing; mod output; pub mod server; diff --git a/crates/pet-jsonrpc/src/server.rs b/crates/pet-jsonrpc/src/server.rs index a29fa5e0..a88fb6e8 100644 --- a/crates/pet-jsonrpc/src/server.rs +++ b/crates/pet-jsonrpc/src/server.rs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{send_error, RequestId}; +use crate::{framing::read_frame, send_error, RequestId}; use serde_json::{self, Value}; use std::{ collections::HashMap, - io::{self, BufRead, BufReader, Read}, + io::{self, BufRead, BufReader}, sync::{mpsc, Arc}, thread, time::Duration, @@ -70,6 +70,11 @@ impl HandlersKeyedByMethodName { } fn handle_request(&self, message: Value) { + let Value::Object(message) = message else { + (self.send_error)(None, -32600, "Invalid JSONRPC request".to_string()); + return; + }; + let id = match message.get("id") { None => None, Some(Value::String(id)) => Some(RequestId::String(id.clone())), @@ -80,11 +85,36 @@ impl HandlersKeyedByMethodName { return; } }; - match message["method"].as_str() { + + if !matches!(message.get("jsonrpc"), Some(Value::String(version)) if version == "2.0") { + (self.send_error)(id.as_ref(), -32600, "Invalid JSONRPC request".to_string()); + return; + } + + match message.get("method").and_then(Value::as_str) { Some(method) => { + let params = match message.get("params") { + None | Some(Value::Null) => Value::Null, + Some(params @ (Value::Object(_) | Value::Array(_))) => params.clone(), + Some(_) => { + if let Some(id) = id.as_ref() { + (self.send_error)( + Some(id), + -32602, + "JSONRPC params must be an object or array".to_string(), + ); + } else { + log::error!( + "Ignoring JSONRPC notification with invalid params for method {method}" + ); + } + return; + } + }; + if let Some(id) = id { if let Some(handler) = self.requests.get(method) { - handler(self.context.clone(), id, message["params"].clone()); + handler(self.context.clone(), id, params); } else { eprint!("Failed to find handler for method: {method}"); (self.send_error)( @@ -94,12 +124,13 @@ impl HandlersKeyedByMethodName { ); } } else if let Some(handler) = self.notifications.get(method) { - handler(self.context.clone(), message["params"].clone()); + handler(self.context.clone(), params); } else { eprint!("Failed to find handler for method: {method}"); } } None => { + let message = Value::Object(message); eprint!("Failed to get method from message: {message}"); (self.send_error)( id.as_ref(), @@ -112,8 +143,6 @@ impl HandlersKeyedByMethodName { } const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(20); -const MAX_HEADER_BYTES: usize = 8 * 1024; -const MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; /// Runs the standalone process transport until EOF or a fatal I/O error. /// Pending output is discarded at shutdown; callers must finish subprocess cleanup @@ -141,7 +170,7 @@ fn close_transport( fn read_input(mut reader: impl BufRead, sender: mpsc::SyncSender>>>) { loop { - let frame = read_payload(&mut reader); + let frame = read_frame(&mut reader); let terminal = !matches!(&frame, Ok(Some(_))); if sender.send(frame).is_err() || terminal { return; @@ -177,65 +206,6 @@ fn dispatch_input( } } -fn read_payload(reader: &mut impl BufRead) -> io::Result>> { - let mut header = String::new(); - let count = (&mut *reader) - .take(MAX_HEADER_BYTES as u64 + 1) - .read_line(&mut header)?; - if count == 0 { - return Ok(None); - } - if count > MAX_HEADER_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "JSONRPC header exceeds limit", - )); - } - if !header.ends_with('\n') { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "Truncated JSONRPC header", - )); - } - let length = get_content_length(&header) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - if length > MAX_PAYLOAD_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "JSONRPC payload exceeds limit", - )); - } - let mut separator = String::new(); - (&mut *reader) - .take((MAX_HEADER_BYTES - count) as u64 + 1) - .read_line(&mut separator)?; - if count + separator.len() > MAX_HEADER_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "JSONRPC header exceeds limit", - )); - } - if !separator.ends_with('\n') { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "Truncated JSONRPC header separator", - )); - } - if separator != "\r\n" && separator != "\n" { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Expected a blank JSONRPC header separator", - )); - } - let mut payload = Vec::new(); - payload - .try_reserve_exact(length) - .map_err(io::Error::other)?; - payload.resize(length, 0); - reader.read_exact(&mut payload)?; - Ok(Some(payload)) -} - fn handle_payload( handlers: &HandlersKeyedByMethodName, payload: impl AsRef<[u8]>, @@ -245,31 +215,12 @@ fn handle_payload( Ok(()) } -/// Parses the content length from the given line. -fn get_content_length(line: &str) -> Result { - let line = line.trim(); - if let Some(content_length) = line.find("Content-Length: ") { - let start = content_length + "Content-Length: ".len(); - if let Ok(length) = line[start..].parse::() { - Ok(length) - } else { - Err(format!( - "Failed to parse content length from {} for {}", - &line[start..], - line - )) - } - } else { - Err(format!( - "String 'Content-Length' not found in input => {line}" - )) - } -} - #[cfg(test)] mod tests { use super::*; + use crate::framing::{MAX_HEADER_BYTES, MAX_PAYLOAD_BYTES}; use serde_json::json; + use std::io::Read; use std::sync::Mutex; #[test] @@ -322,9 +273,32 @@ mod tests { }) } - #[test] - fn request_ids_preserve_values_for_dispatch_and_errors() { - for value in [ + fn assert_valid_dispatch_after_invalid_input( + handlers: &HandlersKeyedByMethodName, + context: &TestContext, + ) { + assert!(context.request.lock().unwrap().is_none()); + assert!(context.notification.lock().unwrap().is_none()); + let error_count = context.errors.lock().unwrap().len(); + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": "after-invalid", "method": "method", "params": [42]}), + ); + assert_eq!( + context.request.lock().unwrap().take(), + Some((RequestId::String("after-invalid".into()), json!([42]))) + ); + assert!(context.notification.lock().unwrap().is_none()); + handlers.handle_request(json!({"jsonrpc": "2.0", "method": "method", "params": [7]})); + assert_eq!( + context.notification.lock().unwrap().take(), + Some(json!([7])) + ); + assert!(context.request.lock().unwrap().is_none()); + assert_eq!(context.errors.lock().unwrap().len(), error_count); + } + + fn supported_request_id_values() -> [Value; 10] { + [ json!("request-1"), json!(""), json!("\u{03c0}-request"), @@ -335,7 +309,12 @@ mod tests { json!(i64::MIN), json!(1.5), Value::Null, - ] { + ] + } + + #[test] + fn request_ids_preserve_values_for_dispatch_and_errors() { + for value in supported_request_id_values() { let id = serde_json::from_value::(value.clone()).unwrap(); assert_eq!(serde_json::to_value(&id).unwrap(), value); let context = Arc::new(TestContext::default()); @@ -346,16 +325,21 @@ mod tests { handlers.add_notification_handler("method", |context, params| { *context.notification.lock().unwrap() = Some(params); }); - handlers.handle_request(json!({"id": value, "method": "method", "params": 42})); + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": value, "method": "method", "params": {"value": 42}}), + ); assert_eq!( *context.request.lock().unwrap(), - Some((id.clone(), json!(42))) + Some((id.clone(), json!({"value": 42}))) ); assert!(context.notification.lock().unwrap().is_none()); - handlers.handle_request(json!({"method": "method", "params": 7})); - assert_eq!(context.notification.lock().unwrap().take(), Some(json!(7))); - handlers.handle_request(json!({"id": value, "method": "unknown"})); - handlers.handle_request(json!({"id": value})); + handlers.handle_request(json!({"jsonrpc": "2.0", "method": "method", "params": [7]})); + assert_eq!( + context.notification.lock().unwrap().take(), + Some(json!([7])) + ); + handlers.handle_request(json!({"jsonrpc": "2.0", "id": value, "method": "unknown"})); + handlers.handle_request(json!({"jsonrpc": "2.0", "id": value})); let errors = context.errors.lock().unwrap(); assert_eq!(errors.len(), 2); assert_eq!(errors[0].0, Some(id.clone())); @@ -375,13 +359,16 @@ mod tests { handlers.add_notification_handler("method", |context, params| { *context.notification.lock().unwrap() = Some(params); }); - handlers.handle_request(json!({"method": "method", "params": 7})); - assert_eq!(context.notification.lock().unwrap().take(), Some(json!(7))); + handlers.handle_request(json!({"jsonrpc": "2.0", "method": "method", "params": [7]})); + assert_eq!( + context.notification.lock().unwrap().take(), + Some(json!([7])) + ); assert!(context.request.lock().unwrap().is_none()); assert!(context.errors.lock().unwrap().is_empty()); for value in [json!(true), json!(false), json!([]), json!({"id": 1})] { assert!(serde_json::from_value::(value.clone()).is_err()); - handlers.handle_request(json!({"id": value, "method": "method"})); + handlers.handle_request(json!({"jsonrpc": "2.0", "id": value, "method": "method"})); assert!(context.notification.lock().unwrap().is_none()); assert!(context.request.lock().unwrap().is_none()); assert_eq!( @@ -390,32 +377,192 @@ mod tests { ); } assert!(context.errors.lock().unwrap().is_empty()); - handlers.handle_request(json!({"id": "after-invalid", "method": "method", "params": 42})); + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": "after-invalid", "method": "method", "params": [42]}), + ); assert_eq!( context.request.lock().unwrap().take(), - Some((RequestId::String("after-invalid".into()), json!(42))) + Some((RequestId::String("after-invalid".into()), json!([42]))) ); assert!(context.notification.lock().unwrap().is_none()); assert!(context.errors.lock().unwrap().is_empty()); } #[test] - fn get_content_length_parses_valid_header() { - assert_eq!(get_content_length("Content-Length: 42\r\n").unwrap(), 42); + fn invalid_top_level_values_and_batches_do_not_invoke_handlers() { + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("method", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); + + for message in [ + Value::Null, + json!(false), + json!(42), + json!("request"), + json!([]), + json!([{"jsonrpc": "2.0", "id": 1, "method": "method"}]), + ] { + handlers.handle_request(message); + } + + assert!(context.request.lock().unwrap().is_none()); + assert!(context.notification.lock().unwrap().is_none()); + assert_eq!( + context.errors.lock().unwrap().as_slice(), + vec![(None, -32600, "Invalid JSONRPC request".to_string()); 6] + ); + assert_valid_dispatch_after_invalid_input(&handlers, &context); } #[test] - fn get_content_length_rejects_missing_header() { - let error = get_content_length("Content-Type: application/json\r\n").unwrap_err(); + fn invalid_jsonrpc_versions_do_not_invoke_handlers() { + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("method", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); - assert!(error.contains("String 'Content-Length' not found")); + for message in [ + json!({"method": "method"}), + json!({"jsonrpc": null, "method": "method"}), + json!({"jsonrpc": 2.0, "method": "method"}), + json!({"jsonrpc": true, "method": "method"}), + json!({"jsonrpc": "1.0", "method": "method"}), + json!({"jsonrpc": "2.0 ", "method": "method"}), + ] { + handlers.handle_request(message); + } + + assert!(context.request.lock().unwrap().is_none()); + assert!(context.notification.lock().unwrap().is_none()); + assert_eq!( + context.errors.lock().unwrap().as_slice(), + vec![(None, -32600, "Invalid JSONRPC request".to_string()); 6] + ); + assert_valid_dispatch_after_invalid_input(&handlers, &context); + } + + #[test] + fn valid_ids_round_trip_in_new_envelope_and_params_errors() { + for value in supported_request_id_values() { + let id = serde_json::from_value::(value.clone()).unwrap(); + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + + handlers.handle_request(json!({"jsonrpc": "1.0", "id": value, "method": "method"})); + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": value, "method": "method", "params": true}), + ); + + assert!(context.request.lock().unwrap().is_none()); + assert_eq!( + context.errors.lock().unwrap().as_slice(), + &[ + ( + Some(id.clone()), + -32600, + "Invalid JSONRPC request".to_string() + ), + ( + Some(id.clone()), + -32602, + "JSONRPC params must be an object or array".to_string() + ) + ] + ); + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": value, "method": "method", "params": [42]}), + ); + assert_eq!( + context.request.lock().unwrap().take(), + Some((id, json!([42]))) + ); + assert!(context.notification.lock().unwrap().is_none()); + assert_eq!(context.errors.lock().unwrap().len(), 2); + } } #[test] - fn get_content_length_rejects_non_numeric_length() { - let error = get_content_length("Content-Length: nope\r\n").unwrap_err(); + fn valid_parameter_containers_dispatch_requests_and_notifications() { + for params in [None, Some(Value::Null), Some(json!([])), Some(json!({}))] { + let expected = params.clone().unwrap_or(Value::Null); + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("method", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); - assert!(error.contains("Failed to parse content length")); + let mut request = json!({"jsonrpc": "2.0", "id": 1, "method": "method"}); + let mut notification = json!({"jsonrpc": "2.0", "method": "method"}); + if let Some(params) = params { + request["params"] = params.clone(); + notification["params"] = params; + } + handlers.handle_request(request); + handlers.handle_request(notification); + + assert_eq!( + context.request.lock().unwrap().take(), + Some((1.into(), expected.clone())) + ); + assert_eq!(context.notification.lock().unwrap().take(), Some(expected)); + assert!(context.errors.lock().unwrap().is_empty()); + } + } + + #[test] + fn invalid_parameter_containers_do_not_invoke_handlers() { + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("method", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); + + for (index, params) in [json!(false), json!(42), json!("params")] + .into_iter() + .enumerate() + { + handlers.handle_request( + json!({"jsonrpc": "2.0", "id": index, "method": "method", "params": params}), + ); + handlers + .handle_request(json!({"jsonrpc": "2.0", "method": "method", "params": params})); + } + + assert!(context.request.lock().unwrap().is_none()); + assert!(context.notification.lock().unwrap().is_none()); + assert_eq!( + context + .errors + .lock() + .unwrap() + .iter() + .map(|(id, code, _)| (id.clone(), *code)) + .collect::>(), + vec![ + (Some(0.into()), -32602), + (Some(1.into()), -32602), + (Some(2.into()), -32602) + ] + ); + assert_valid_dispatch_after_invalid_input(&handlers, &context); } #[test] @@ -526,21 +673,42 @@ mod tests { let context = Arc::new(TestContext::default()); let handlers = create_handlers_with_recorded_errors(context.clone()); - let message = json!({ - "jsonrpc": "2.0", - "id": 1, - "params": { "value": 42 } - }); - - handlers.handle_request(message.clone()); + let messages = [ + json!({ + "jsonrpc": "2.0", + "id": 1, + "params": { "value": 42 } + }), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": 42 + }), + ]; + for message in &messages { + handlers.handle_request(message.clone()); + } assert_eq!( context.errors.lock().unwrap().as_slice(), - &[( - Some(1.into()), - -3, - format!("Failed to extract method from JSONRPC payload {message:?}") - )] + &[ + ( + Some(1.into()), + -3, + format!( + "Failed to extract method from JSONRPC payload {:?}", + messages[0] + ) + ), + ( + Some(1.into()), + -3, + format!( + "Failed to extract method from JSONRPC payload {:?}", + messages[1] + ) + ) + ] ); } @@ -567,14 +735,14 @@ mod tests { } #[test] fn input_distinguishes_clean_eof_from_truncated_frames() { - assert!(read_payload(&mut io::Cursor::new(b"")).unwrap().is_none()); + assert!(read_frame(&mut io::Cursor::new(b"")).unwrap().is_none()); for bytes in [ b"Content-Length: 2".as_slice(), b"Content-Length: 2\r\n".as_slice(), b"Content-Length: 2\r\n\r".as_slice(), b"Content-Length: 2\r\n\r\n{".as_slice(), ] { - let error = read_payload(&mut io::Cursor::new(bytes)).unwrap_err(); + let error = read_frame(&mut io::Cursor::new(bytes)).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); } } @@ -588,25 +756,21 @@ mod tests { bytes.extend_from_slice(b"Content-Length: 2\n\n"); bytes.extend_from_slice(second); let mut reader = BufReader::with_capacity(1, io::Cursor::new(bytes)); - assert_eq!(read_payload(&mut reader).unwrap().unwrap(), first); - assert_eq!(read_payload(&mut reader).unwrap().unwrap(), second); - assert!(read_payload(&mut reader).unwrap().is_none()); + assert_eq!(read_frame(&mut reader).unwrap().unwrap(), first); + assert_eq!(read_frame(&mut reader).unwrap().unwrap(), second); + assert!(read_frame(&mut reader).unwrap().is_none()); } #[test] fn input_rejects_oversized_headers_and_payloads_before_body_reads() { let bytes = vec![b'x'; MAX_HEADER_BYTES + 1]; assert_eq!( - read_payload(&mut io::Cursor::new(bytes)) - .unwrap_err() - .kind(), + read_frame(&mut io::Cursor::new(bytes)).unwrap_err().kind(), io::ErrorKind::InvalidData ); let bytes = format!("Content-Length: {}\r\n\r\n", MAX_PAYLOAD_BYTES + 1); assert_eq!( - read_payload(&mut io::Cursor::new(bytes)) - .unwrap_err() - .kind(), + read_frame(&mut io::Cursor::new(bytes)).unwrap_err().kind(), io::ErrorKind::InvalidData ); } diff --git a/crates/pet/tests/jsonrpc_server_test.rs b/crates/pet/tests/jsonrpc_server_test.rs index 3c75670e..7123928c 100644 --- a/crates/pet/tests/jsonrpc_server_test.rs +++ b/crates/pet/tests/jsonrpc_server_test.rs @@ -17,6 +17,19 @@ mod jsonrpc_client; use jsonrpc_client::{EnvironmentNotification, PetJsonRpcClient}; +fn frame_with_headers(payload: &[u8], headers: &[(&str, &str)], line_ending: &[u8]) -> Vec { + let mut frame = Vec::new(); + for (name, value) in headers { + frame.extend_from_slice(name.as_bytes()); + frame.extend_from_slice(b": "); + frame.extend_from_slice(value.as_bytes()); + frame.extend_from_slice(line_ending); + } + frame.extend_from_slice(line_ending); + frame.extend_from_slice(payload); + frame +} + struct RawRpcClient { child: Child, responses: mpsc::Receiver>, @@ -61,9 +74,22 @@ impl RawRpcClient { fn send(&mut self, message: Value) { let body = serde_json::to_vec(&message).unwrap(); + self.send_payload(&body); + } + + fn send_payload(&mut self, body: &[u8]) { + let content_length = body.len().to_string(); + let frame = frame_with_headers( + body, + &[("Content-Length", content_length.as_str())], + b"\r\n", + ); + self.write_raw(&frame); + } + + fn write_raw(&mut self, bytes: &[u8]) { let stdin = self.child.stdin.as_mut().expect("PET stdin must be piped"); - write!(stdin, "Content-Length: {}\r\n\r\n", body.len()).unwrap(); - stdin.write_all(&body).unwrap(); + stdin.write_all(bytes).unwrap(); stdin.flush().unwrap(); } @@ -92,6 +118,188 @@ impl Drop for RawRpcClient { } } +fn assert_rpc_error(response: &Value, expected_id: &Value, expected_code: i64) { + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response.get("id"), Some(expected_id)); + assert_eq!(response["error"]["code"], expected_code); + assert!(response.get("result").is_none()); +} + +#[test] +fn native_wire_accepts_header_variants_fragmentation_and_consecutive_frames() { + let mut client = RawRpcClient::spawn(); + let crlf_payload = br#"{"jsonrpc":"2.0","id":"crlf-content-type-first","method":"info"}"#; + let lf_payload = + "{\"jsonrpc\":\"2.0\",\"id\":\"lf-snowman-\u{2603}\",\"method\":\"info\"}".as_bytes(); + let no_content_type_payload = br#"{"jsonrpc":"2.0","id":"no-content-type","method":"info"}"#; + assert!( + lf_payload.iter().any(|byte| !byte.is_ascii()), + "fixture must exercise byte lengths rather than character counts" + ); + + let crlf_length = crlf_payload.len().to_string(); + let lf_length = lf_payload.len().to_string(); + let no_content_type_length = no_content_type_payload.len().to_string(); + let mut wire = frame_with_headers( + crlf_payload, + &[ + ("cOnTeNt-TyPe", "application/vscode-jsonrpc; charset=utf-8"), + ("X-Before-Length", "accepted"), + ("cOnTeNt-LeNgTh", crlf_length.as_str()), + ], + b"\r\n", + ); + wire.extend(frame_with_headers( + lf_payload, + &[ + ("CONTENT-LENGTH", lf_length.as_str()), + ("x-after-length", "accepted"), + ("CONTENT-TYPE", "application/vscode-jsonrpc; charset=utf-8"), + ], + b"\n", + )); + wire.extend(frame_with_headers( + no_content_type_payload, + &[ + ("X-Optional-Content-Type", "omitted"), + ("Content-Length", no_content_type_length.as_str()), + ], + b"\r\n", + )); + + for fragment in wire.chunks(3) { + client.write_raw(fragment); + } + + for expected_id in [ + "crlf-content-type-first", + "lf-snowman-\u{2603}", + "no-content-type", + ] { + let response = client.receive(); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], expected_id); + assert!(response["result"]["petVersion"].is_string()); + assert!(response.get("error").is_none()); + } +} + +#[test] +fn complete_invalid_json_and_utf8_frames_recover_for_the_next_frame() { + let mut client = RawRpcClient::spawn(); + + client.send_payload(br#"{"jsonrpc":"2.0","id":"malformed","method":"info""#); + assert_rpc_error(&client.receive(), &Value::Null, -32700); + + client.send_payload(&[b'{', b'"', 0xff, b'"', b':', b'1', b'}']); + assert_rpc_error(&client.receive(), &Value::Null, -32700); + + client.send(json!({ + "jsonrpc": "2.0", + "id": "after-parse-errors", + "method": "info" + })); + let response = client.receive(); + assert_eq!(response["id"], "after-parse-errors"); + assert!(response["result"]["petVersion"].is_string()); +} + +#[test] +fn native_wire_validates_envelopes_params_and_legacy_errors() { + let mut client = RawRpcClient::spawn(); + + for invalid in [ + Value::Null, + json!(false), + json!(42), + json!("request"), + json!([]), + json!([{"jsonrpc": "2.0", "id": "batch", "method": "info"}]), + ] { + client.send(invalid); + assert_rpc_error(&client.receive(), &Value::Null, -32600); + } + + for request in [ + json!({"id": "missing-version", "method": "info"}), + json!({"jsonrpc": "1.0", "id": "wrong-version", "method": "info"}), + json!({"jsonrpc": 2.0, "id": 17, "method": "info"}), + ] { + let expected_id = request["id"].clone(); + client.send(request); + assert_rpc_error(&client.receive(), &expected_id, -32600); + } + + for invalid_id in [json!([]), json!({"nested": "id"})] { + client.send(json!({ + "jsonrpc": "2.0", + "id": invalid_id, + "method": "info" + })); + assert_rpc_error(&client.receive(), &Value::Null, -32600); + } + + for (index, params) in [json!(false), json!(7), json!("scalar")] + .into_iter() + .enumerate() + { + let id = json!(format!("invalid-params-{index}")); + client.send(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "info", + "params": params + })); + assert_rpc_error(&client.receive(), &id, -32602); + } + + for (id, params) in [ + ("missing-params", None), + ("null-params", Some(Value::Null)), + ("array-params", Some(json!([]))), + ("object-params", Some(json!({}))), + ] { + let mut request = json!({"jsonrpc": "2.0", "id": id, "method": "info"}); + if let Some(params) = params { + request["params"] = params; + } + client.send(request); + let response = client.receive(); + assert_eq!(response["id"], id); + assert!(response["result"]["petVersion"].is_string()); + } + + client.send(json!({ + "jsonrpc": "2.0", + "method": "info", + "params": "invalid-notification-params" + })); + client.send(json!({ + "jsonrpc": "2.0", + "id": "notification-sentinel", + "method": "info" + })); + let response = client.receive(); + assert_eq!(response["id"], "notification-sentinel"); + assert!(response["result"]["petVersion"].is_string()); + + client.send(json!({"jsonrpc": "2.0", "id": "missing-method"})); + assert_rpc_error(&client.receive(), &json!("missing-method"), -3); + client.send(json!({ + "jsonrpc": "2.0", + "id": "unknown-method", + "method": "unknown" + })); + assert_rpc_error(&client.receive(), &json!("unknown-method"), -1); + client.send(json!({ + "jsonrpc": "2.0", + "id": "handler-invalid-param", + "method": "resolve", + "params": {"executable": 42} + })); + assert_rpc_error(&client.receive(), &json!("handler-invalid-param"), -4); +} + #[test] fn request_ids_round_trip_through_success_and_error_responses() { let mut client = RawRpcClient::spawn(); @@ -522,6 +730,12 @@ impl ShutdownFixture { stdin.write_all(body).unwrap(); stdin.flush().unwrap(); } + + fn write_raw(&mut self, bytes: &[u8]) { + let stdin = self.child.stdin.as_mut().unwrap(); + stdin.write_all(bytes).unwrap(); + stdin.flush().unwrap(); + } } impl Drop for ShutdownFixture { @@ -535,6 +749,76 @@ impl Drop for ShutdownFixture { } } +fn assert_fatal_framing_input(name: &str, input: &[u8]) { + let mut fixture = ShutdownFixture::spawn(); + fixture.write_raw(input); + let started = Instant::now(); + fixture.child.stdin.take(); + let status = jsonrpc_client::wait_for_exit(&mut fixture.child, Duration::from_secs(1)) + .unwrap_or_else(|error| panic!("{name} did not terminate within one second: {error}")); + assert!( + !status.success(), + "{name} must terminate the server unsuccessfully" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "{name} exceeded the shutdown budget" + ); + let mut stderr = Vec::new(); + fixture + .child + .stderr + .take() + .unwrap() + .read_to_end(&mut stderr) + .unwrap(); + assert!(!stderr.is_empty(), "{name} must be reported"); + assert!( + stderr.len() < 4096, + "{name} produced an error flood of {} bytes", + stderr.len() + ); +} + +#[test] +fn invalid_and_oversize_framing_terminates_with_bounded_diagnostics() { + const MAX_HEADER_BYTES: usize = 8 * 1024; + const MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; + + let mut oversized_header = b"X-Oversized: ".to_vec(); + oversized_header.resize(MAX_HEADER_BYTES + 1, b'x'); + oversized_header.extend_from_slice(b"\r\n\r\n"); + + let cases = [ + ( + "invalid Content-Length", + b"Content-Length: twelve\r\n\r\n".to_vec(), + ), + ( + "missing Content-Length", + b"Content-Type: application/json\r\n\r\n".to_vec(), + ), + ( + "duplicate Content-Length", + b"Content-Length: 0\r\nContent-Length: 0\r\n\r\n".to_vec(), + ), + ( + "overflowing Content-Length", + b"Content-Length: 184467440737095516160\r\n\r\n".to_vec(), + ), + ( + "oversize Content-Length", + format!("Content-Length: {}\r\n\r\n", MAX_PAYLOAD_BYTES + 1).into_bytes(), + ), + ("malformed header", b"Not-A-Header\r\n\r\n".to_vec()), + ("oversize header", oversized_header), + ]; + + for (name, input) in cases { + assert_fatal_framing_input(name, &input); + } +} + #[test] fn stdin_eof_after_exchange_exits_cleanly_within_one_second() { let client = PetJsonRpcClient::spawn().unwrap(); diff --git a/docs/JSONRPC.md b/docs/JSONRPC.md index 0d371a2b..dde61965 100644 --- a/docs/JSONRPC.md +++ b/docs/JSONRPC.md @@ -27,11 +27,21 @@ Malformed JSON in a complete frame instead receives a Parse Error (`-32700`, `id: null`), after which subsequent frames can still be processed. Protocol stdout contains framed JSONRPC only. -Input is currently one `Content-Length` header followed by a blank line and the -specified number of UTF-8 payload bytes. Both CRLF and LF line endings are accepted. -Headers including the separator are limited to 8 KiB, and payloads to 16 MiB, before -payload allocation. Multi-header input parsing is tracked separately in -[#532](https://github.com/microsoft/python-environment-tools/issues/532). +Each input frame contains ASCII headers through a blank line, followed by exactly +`Content-Length` UTF-8 payload **bytes** (not characters). Both CRLF and LF line +endings are accepted. Header names are ASCII-case-insensitive; optional +`Content-Type` and other well-formed headers may precede or follow `Content-Length`. +PET always decodes payloads as UTF-8; it does not negotiate another encoding from +`Content-Type`. Fragmented reads and consecutive frames preserve byte boundaries. + +Exactly one `Content-Length` is required. Its value is decimal digits, optionally +surrounded by spaces or tabs; signs, fractions, duplicate/missing lengths, overflow, +and malformed header names or values are rejected. Total raw headers (including +line endings and the blank separator) are limited to 8 KiB, and payloads to 16 MiB, +before payload allocation. Both exact limits are accepted. Invalid framing closes +the connection unsuccessfully rather than attempting to guess the next boundary. +A complete frame containing invalid UTF-8 or invalid JSON gets the recoverable +Parse Error described above. One process-lifetime writer emits accepted frames in FIFO order, so a refresh reply cannot overtake notifications already admitted before it. Each serialized output @@ -63,8 +73,27 @@ spelling is not preserved. Prefer string IDs when exact values exceed the 64-bit or the precision of a client's numeric type. JSONRPC recommends avoiding fractional and null IDs. Only an absent `id` denotes a notification. Boolean, array, and object IDs produce an Invalid -Request error (`-32600`) with `id: null` and do not invoke a handler. Other existing method and -parameter error codes are unchanged. Notifications do not receive request replies. +Request error (`-32600`) with `id: null` and do not invoke a handler. Valid notifications +do not receive request replies. + +## Request envelopes and errors + +Each payload must be a JSON object with `"jsonrpc": "2.0"`; batches are not supported. +Non-object payloads and missing/invalid versions produce Invalid Request (`-32600`) +without invoking a handler. Errors preserve a valid parsed ID where available, or +use `id: null` otherwise. An invalid envelope with no ID is still an error, not a +valid notification. + +If present, `params` must be an object or array. Absent params and explicit `null` +remain supported for compatibility; method-specific schemas still apply. Other +parameter containers produce Invalid Params (`-32602`) for requests. Otherwise-valid +notifications with invalid params are logged and not dispatched or replied to. +Existing PET method-level codes are retained: missing/nonstring method (`-3`), +unknown request method (`-1`), and handler-specific parameter errors (`-4`). Unknown +notification methods are logged without a reply. These legacy codes are not the +standard JSONRPC equivalents and clients should retain their existing handling. +Complete-frame envelope/parameter errors do not prevent subsequent frames from +being processed. # Info Request