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}