Skip to main content

jdwp_client/
protocol.rs

1// JDWP protocol definitions and packet handling
2//
3// Reference: https://docs.oracle.com/javase/8/docs/platform/jpda/jdwp/jdwp-protocol.html
4
5use bytes::{Buf, BufMut, BytesMut};
6use thiserror::Error;
7
8// JDWP uses big-endian (network byte order) for all multi-byte values
9// This is architecture-independent (works on Intel, ARM M1/M2/M3, etc.)
10
11pub type JdwpResult<T> = Result<T, JdwpError>;
12
13#[derive(Debug, Error)]
14pub enum JdwpError {
15    #[error("IO error: {0}")]
16    Io(#[from] std::io::Error),
17
18    #[error("Protocol error: {0}")]
19    Protocol(String),
20
21    #[error("Invalid handshake")]
22    InvalidHandshake,
23
24    #[error("JDWP error code {0}: {1}")]
25    JdwpErrorCode(u16, String),
26
27    /// The connection to the debuggee ended, carrying **why** it ended.
28    ///
29    /// The payload is the point. Every one of these was once reported as `Reply channel closed`, a
30    /// message produced by four different worlds — a dead socket, a dropped event consumer, a lapsed
31    /// reply, and a loop that had already gone — none of which the reader could tell apart. The event
32    /// loop is the only thing that knows which, and it used to log the cause at a level nobody enables
33    /// and then throw the value away, so a debuggee that died mid-question was indistinguishable from a
34    /// bug in this crate. See [`crate::eventloop`].
35    #[error("connection to the debuggee closed: {0}")]
36    ConnectionClosed(String),
37
38    /// The bytes where a JDWP packet header should be are not a JDWP packet header (TEST-24, #65).
39    ///
40    /// **Its own variant because the remedy is different from every other protocol failure.** A
41    /// [`Protocol`](Self::Protocol) error means we mis-read something the debuggee legitimately sent; this
42    /// means the stream itself is not ours to parse — a peer that is not a JDWP agent, a socket carrying
43    /// someone else's traffic, or a stream that has lost packet alignment. Nothing in the *command* is
44    /// wrong, so retrying the command is pointless, and the session cannot be salvaged by reading on.
45    ///
46    /// It exists because the honest version of this was being reported as its own opposite. A header whose
47    /// length field reads `1701737519` was announced as `Packet too large: 1701737519 bytes` — a sentence
48    /// that sends the reader looking for an enormous reply, when those four bytes are the ASCII text
49    /// `ent/` and no large packet was ever involved. The payload carries the bytes so the *speaker* can be
50    /// identified rather than guessed at.
51    #[error("the debuggee's stream is not JDWP-framed at this point: {0}")]
52    NotJdwpFramed(String),
53
54    /// A command was sent, the connection stayed up, and no reply arrived within the budget.
55    ///
56    /// Distinct from [`ConnectionClosed`](Self::ConnectionClosed) because the remedy differs: the socket
57    /// is still there, so the session is worth keeping and the *question* is what failed. Distinct from
58    /// [`InvokeTimeout`](Self::InvokeTimeout) because nothing was being executed in the debuggee — this
59    /// is the JVM not answering a question it should have answered.
60    #[error(
61        "no reply from the debuggee within {0}s (the connection is still open; the command was abandoned)"
62    )]
63    ReplyTimeout(u64),
64
65    /// A debuggee invocation did not return within its budget.
66    ///
67    /// Distinct from a lost reply on purpose. `INVOKE_SINGLE_THREADED` runs only the target thread, so a
68    /// method needing a monitor held by one of the *other* (still suspended) threads cannot finish — the
69    /// classic debugger-invocation deadlock. That is not a protocol failure and not something the caller
70    /// did wrong, and it must be reported as itself rather than folded into a generic error, because the
71    /// right response is different: render shallowly and move on.
72    ///
73    /// **"Render shallowly and move on" is what WE do, and the message now says what the DEBUGGEE does**
74    /// (DUMP-8, #123), because the two had been allowed to sound like the same thing. Measured on Temurin
75    /// 11.0.32 and 21.0.12 against a lock held 3000 ms past a 2000 ms budget: the call completes when the
76    /// lock is finally released and the JVM **re-suspends the thread at that moment** — 1.2 s after this
77    /// side had given up, resumed the thread and moved on. From then on the thread is suspended for good.
78    /// Nothing here clears it: the watchdog resumes a suspended *VM*, and the VM is running. So the one
79    /// remedy is a caller-issued `debug.continue` (which decrements every thread) or `debug.resume_thread`,
80    /// and a message that did not name it left the reader believing the cost was the two seconds.
81    #[error(
82        "invocation did not return within {0}ms — the debuggee thread is STILL INSIDE THE CALL and JDWP \
83         cannot cancel it (usually a monitor held by another thread). When the call does return the JVM \
84         re-suspends that thread, and nothing here clears that — the watchdog only resumes a suspended VM. \
85         Use debug.continue, or debug.resume_thread on it"
86    )]
87    InvokeTimeout(u64),
88
89    /// The connection is in read-only mode and something tried to execute code in the debuggee.
90    ///
91    /// Enforced at the point of invocation rather than by inspecting expressions up in the MCP layer,
92    /// because invocation is reached from many directions — a `toString()` render, a `List.get`
93    /// subscript, `valueOf` boxing, a breakpoint condition — and a text-level guard misses whichever
94    /// one nobody thought of.
95    #[error("read-only connection: refusing {0} in the debuggee")]
96    ReadOnly(String),
97}
98
99// JDWP handshake string
100pub const JDWP_HANDSHAKE: &[u8] = b"JDWP-Handshake";
101
102// Packet structure:
103// length (4 bytes) - includes header
104// id (4 bytes)
105// flags (1 byte) - 0x00 = command, 0x80 = reply
106// [Command packet: command set (1 byte) + command (1 byte)]
107// [Reply packet: error code (2 bytes)]
108// data (variable)
109
110pub const HEADER_SIZE: usize = 11;
111pub const REPLY_FLAG: u8 = 0x80;
112
113#[derive(Debug, Clone)]
114pub struct CommandPacket {
115    pub id: u32,
116    pub command_set: u8,
117    pub command: u8,
118    pub data: Vec<u8>,
119}
120
121#[derive(Debug, Clone)]
122pub struct ReplyPacket {
123    pub id: u32,
124    pub error_code: u16,
125    pub data: Vec<u8>,
126}
127
128impl CommandPacket {
129    #[must_use]
130    pub const fn new(id: u32, command_set: u8, command: u8) -> Self {
131        Self { id, command_set, command, data: Vec::new() }
132    }
133
134    #[must_use]
135    pub fn encode(&self) -> Vec<u8> {
136        let length = HEADER_SIZE + self.data.len();
137        let mut buf = BytesMut::with_capacity(length);
138
139        buf.put_u32(u32::try_from(length).unwrap_or(u32::MAX));
140        buf.put_u32(self.id);
141        buf.put_u8(0x00); // command flag
142        buf.put_u8(self.command_set);
143        buf.put_u8(self.command);
144        buf.put_slice(&self.data);
145
146        buf.to_vec()
147    }
148}
149
150/// `ABSENT_INFORMATION` — the class is there, the debug attribute being asked for is not (`javac
151/// -g:none`, a synthetic class, a JVM without an optional table).
152///
153/// Public because callers *branch* on this one instead of reporting it. It is a fact about how the
154/// class was compiled, and telling it apart from a transport failure is the difference between "this
155/// build has no line numbers" and "the debugger is broken".
156pub const ERR_ABSENT_INFORMATION: u16 = 101;
157
158/// `NOT_IMPLEMENTED` — the VM never had the optional capability behind the command. Public for the
159/// same reason as [`ERR_ABSENT_INFORMATION`]: for an optional command it is an answer, not a fault.
160pub const ERR_NOT_IMPLEMENTED: u16 = 99;
161
162/// `INVALID_OBJECT` — the object id is not one this JVM currently knows.
163///
164/// Public for the same reason as the two above, and it is the sharpest example of the rule: a JDWP
165/// object id is a **weak** reference, so this is what a perfectly valid id becomes once the object it
166/// named is collected. `CONTEXT.md` calls that **Vanished**, and reporting it as a transport failure
167/// would blame the debugger for the garbage collector doing its job (TRACE-10, #85).
168pub const ERR_INVALID_OBJECT: u16 = 20;
169
170/// JDWP error-code to human-readable name mapping.
171const ERROR_MESSAGES: &[(u16, &str)] = &[
172    (0, "NONE"),
173    (10, "INVALID_THREAD"),
174    (11, "INVALID_THREAD_GROUP"),
175    (12, "INVALID_PRIORITY"),
176    (13, "THREAD_NOT_SUSPENDED"),
177    (14, "THREAD_SUSPENDED"),
178    (20, "INVALID_OBJECT"),
179    (21, "INVALID_CLASS"),
180    (22, "CLASS_NOT_PREPARED"),
181    (23, "INVALID_METHODID"),
182    (24, "INVALID_LOCATION"),
183    (25, "INVALID_FIELDID"),
184    (30, "INVALID_FRAMEID"),
185    (31, "NO_MORE_FRAMES"),
186    (32, "OPAQUE_FRAME"),
187    (33, "NOT_CURRENT_FRAME"),
188    (34, "TYPE_MISMATCH"),
189    (35, "INVALID_SLOT"),
190    (40, "DUPLICATE"),
191    (41, "NOT_FOUND"),
192    (50, "INVALID_MONITOR"),
193    (51, "NOT_MONITOR_OWNER"),
194    (52, "INTERRUPT"),
195    (60, "INVALID_CLASS_FORMAT"),
196    (61, "CIRCULAR_CLASS_DEFINITION"),
197    (62, "FAILS_VERIFICATION"),
198    (63, "ADD_METHOD_NOT_IMPLEMENTED"),
199    (64, "SCHEMA_CHANGE_NOT_IMPLEMENTED"),
200    (65, "INVALID_TYPESTATE"),
201    (66, "HIERARCHY_CHANGE_NOT_IMPLEMENTED"),
202    (67, "DELETE_METHOD_NOT_IMPLEMENTED"),
203    (68, "UNSUPPORTED_VERSION"),
204    (69, "NAMES_DONT_MATCH"),
205    (70, "CLASS_MODIFIERS_CHANGE_NOT_IMPLEMENTED"),
206    (71, "METHOD_MODIFIERS_CHANGE_NOT_IMPLEMENTED"),
207    (99, "NOT_IMPLEMENTED"),
208    (100, "NULL_POINTER"),
209    (101, "ABSENT_INFORMATION"),
210    (102, "INVALID_EVENT_TYPE"),
211    (103, "ILLEGAL_ARGUMENT"),
212    (110, "OUT_OF_MEMORY"),
213    (111, "ACCESS_DENIED"),
214    (112, "VM_DEAD"),
215    (113, "INTERNAL"),
216    (115, "UNATTACHED_THREAD"),
217    (500, "INVALID_TAG"),
218    (502, "ALREADY_INVOKING"),
219    (503, "INVALID_INDEX"),
220    (504, "INVALID_LENGTH"),
221    (506, "INVALID_STRING"),
222    (507, "INVALID_CLASS_LOADER"),
223    (508, "INVALID_ARRAY"),
224    (509, "TRANSPORT_LOAD"),
225    (510, "TRANSPORT_INIT"),
226    (511, "NATIVE_METHOD"),
227    (512, "INVALID_COUNT"),
228];
229
230impl ReplyPacket {
231    /// Decode a reply packet from its raw bytes.
232    ///
233    /// # Errors
234    /// Returns a [`JdwpError`] if the buffer is too short or the reply flag is invalid.
235    pub fn decode(mut buf: &[u8]) -> JdwpResult<Self> {
236        if buf.len() < HEADER_SIZE {
237            return Err(JdwpError::Protocol("Reply packet too short".to_string()));
238        }
239
240        let _length = buf.get_u32();
241        let id = buf.get_u32();
242        let flags = buf.get_u8();
243
244        if flags != REPLY_FLAG {
245            return Err(JdwpError::Protocol(format!("Invalid reply flag: {flags:#x}")));
246        }
247
248        let error_code = buf.get_u16();
249        let data = buf.to_vec();
250
251        Ok(Self { id, error_code, data })
252    }
253
254    #[must_use]
255    pub const fn is_error(&self) -> bool {
256        self.error_code != 0
257    }
258
259    /// Return an error if the reply carries a non-zero JDWP error code.
260    ///
261    /// # Errors
262    /// Returns a [`JdwpError::JdwpErrorCode`] when the reply's error code is non-zero.
263    pub fn check_error(&self) -> JdwpResult<()> {
264        if self.is_error() {
265            Err(JdwpError::JdwpErrorCode(self.error_code, self.error_message().to_string()))
266        } else {
267            Ok(())
268        }
269    }
270
271    #[must_use]
272    pub fn data(&self) -> &[u8] {
273        &self.data
274    }
275
276    #[must_use]
277    pub fn error_message(&self) -> &'static str {
278        ERROR_MESSAGES
279            .iter()
280            .find(|&&(code, _)| code == self.error_code)
281            .map_or("UNKNOWN_ERROR", |&(_, name)| name)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_command_packet_encode() {
291        let packet = CommandPacket::new(1, 1, 1);
292        let encoded = packet.encode();
293
294        assert_eq!(encoded.len(), HEADER_SIZE);
295        assert_eq!(&encoded[0..4], &[0, 0, 0, 11]); // length (big-endian)
296        assert_eq!(&encoded[4..8], &[0, 0, 0, 1]); // id (big-endian)
297        assert_eq!(encoded[8], 0x00); // command flag
298        assert_eq!(encoded[9], 1); // command set
299        assert_eq!(encoded[10], 1); // command
300    }
301
302    #[test]
303    fn test_big_endian_encoding() {
304        // Verify we're using big-endian (network byte order)
305        // This test ensures architecture independence (Intel vs ARM M1/M2/M3)
306        let packet = CommandPacket::new(0x1234_5678, 1, 1);
307        let encoded = packet.encode();
308
309        // ID should be encoded as big-endian: 0x12345678
310        assert_eq!(&encoded[4..8], &[0x12, 0x34, 0x56, 0x78]);
311
312        // NOT little-endian (which would be [0x78, 0x56, 0x34, 0x12])
313        assert_ne!(&encoded[4..8], &[0x78, 0x56, 0x34, 0x12]);
314    }
315
316    #[test]
317    fn test_reply_packet_decode() {
318        // Construct a reply packet manually with big-endian values
319        let reply_data = vec![
320            0, 0, 0, 11, // length = 11 (big-endian)
321            0, 0, 0, 1,    // id = 1 (big-endian)
322            0x80, // reply flag
323            0, 0, // error code = 0 (big-endian)
324        ];
325
326        let packet = ReplyPacket::decode(&reply_data).unwrap();
327        assert_eq!(packet.id, 1);
328        assert_eq!(packet.error_code, 0);
329        assert!(!packet.is_error());
330    }
331}