use crate::events::{parse_event_packet, EventSet};
use crate::protocol::{CommandPacket, JdwpError, JdwpResult, ReplyPacket, HEADER_SIZE, REPLY_FLAG};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, error, info, warn};
const MAX_PACKET_SIZE: usize = 10 * 1024 * 1024;
const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub struct CommandRequest {
pub packet: CommandPacket,
pub reply_tx: oneshot::Sender<JdwpResult<ReplyPacket>>,
}
#[derive(Clone, Debug)]
pub struct EventLoopHandle {
command_tx: mpsc::Sender<CommandRequest>,
event_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<EventSet>>>,
shutdown: Arc<std::sync::OnceLock<String>>,
}
impl EventLoopHandle {
pub async fn send_command(&self, packet: CommandPacket) -> JdwpResult<ReplyPacket> {
self.issue(packet).await?.reply().await
}
pub async fn issue(&self, packet: CommandPacket) -> JdwpResult<InFlight> {
let (reply_tx, reply_rx) = oneshot::channel();
let id = packet.id;
let request = CommandRequest { packet, reply_tx };
self.command_tx.send(request).await.map_err(|_| self.lost("the command was never sent"))?;
Ok(InFlight { id, reply_rx, shutdown: Arc::clone(&self.shutdown) })
}
fn lost(&self, what: &str) -> JdwpError {
lost(&self.shutdown, what)
}
pub async fn try_recv_event(&self) -> Option<EventSet> {
let mut rx = self.event_rx.lock().await;
rx.try_recv().ok()
}
pub async fn recv_event(&self) -> Option<EventSet> {
let mut rx = self.event_rx.lock().await;
rx.recv().await
}
}
#[must_use = "an issued command is on its way to the debuggee; dropping this discards its reply"]
pub struct InFlight {
id: u32,
reply_rx: oneshot::Receiver<JdwpResult<ReplyPacket>>,
shutdown: Arc<std::sync::OnceLock<String>>,
}
impl InFlight {
#[must_use]
pub const fn id(&self) -> u32 {
self.id
}
pub async fn reply(self) -> JdwpResult<ReplyPacket> {
let Self { reply_rx, shutdown, .. } = self;
reply_rx.await.map_err(|_| lost(&shutdown, "the command was sent and its reply was dropped"))?
}
}
fn lost(shutdown: &std::sync::OnceLock<String>, what: &str) -> JdwpError {
shutdown.get().map_or_else(
|| JdwpError::Protocol(format!("the event loop stopped without recording a reason, and {what}")),
|cause| JdwpError::ConnectionClosed(cause.clone()),
)
}
#[must_use]
pub fn spawn_event_loop(reader: OwnedReadHalf, writer: OwnedWriteHalf) -> EventLoopHandle {
let (command_tx, command_rx) = mpsc::channel(32);
let (event_tx, event_rx) = mpsc::channel(256);
let shutdown = Arc::new(std::sync::OnceLock::new());
tokio::spawn(event_loop_task(reader, writer, command_rx, event_tx, Arc::clone(&shutdown)));
EventLoopHandle { command_tx, event_rx: Arc::new(tokio::sync::Mutex::new(event_rx)), shutdown }
}
struct PendingReply {
sender: oneshot::Sender<JdwpResult<ReplyPacket>>,
sent_at: tokio::time::Instant,
}
const PACKET_CHANNEL_DEPTH: usize = 8;
fn spawn_packet_reader(mut reader: OwnedReadHalf) -> mpsc::Receiver<JdwpResult<(bool, u32, Vec<u8>)>> {
let (tx, rx) = mpsc::channel(PACKET_CHANNEL_DEPTH);
tokio::spawn(async move {
loop {
let result = read_packet(&mut reader).await;
let fatal = result.is_err();
if tx.send(result).await.is_err() {
break;
}
if fatal {
break;
}
}
});
rx
}
async fn event_loop_task(
reader: OwnedReadHalf,
mut writer: OwnedWriteHalf,
mut command_rx: mpsc::Receiver<CommandRequest>,
event_tx: mpsc::Sender<EventSet>,
shutdown: Arc<std::sync::OnceLock<String>>,
) {
info!("Event loop started");
let mut pending_replies: HashMap<u32, PendingReply> = HashMap::new();
let mut cleanup_interval = tokio::time::interval(tokio::time::Duration::from_secs(10));
let mut packets = spawn_packet_reader(reader);
let cause = loop {
tokio::select! {
Some(cmd) = command_rx.recv() => {
handle_outgoing_command(&mut writer, &mut pending_replies, cmd).await;
}
_ = cleanup_interval.tick() => {
cleanup_pending_replies(&mut pending_replies);
}
received = packets.recv() => {
match received {
Some(result) => {
if let Some(cause) = handle_incoming_packet(&mut pending_replies, &event_tx, result) {
break cause;
}
}
None => break "the packet reader stopped without reporting a reason".to_string(),
}
}
}
};
info!("Event loop shutting down: {}", cause);
let _ = shutdown.set(cause);
drop(pending_replies);
}
async fn handle_outgoing_command(
writer: &mut OwnedWriteHalf,
pending_replies: &mut HashMap<u32, PendingReply>,
cmd: CommandRequest,
) {
let packet_id = cmd.packet.id;
debug!("Sending command id={}", packet_id);
let encoded = cmd.packet.encode();
if let Err(e) = writer.write_all(&encoded).await {
error!("Failed to write command: {}", e);
cmd.reply_tx.send(Err(JdwpError::Io(e))).ok();
return;
}
if let Err(e) = writer.flush().await {
error!("Failed to flush command: {}", e);
cmd.reply_tx.send(Err(JdwpError::Io(e))).ok();
return;
}
pending_replies
.insert(packet_id, PendingReply { sender: cmd.reply_tx, sent_at: tokio::time::Instant::now() });
}
fn cleanup_pending_replies(pending_replies: &mut HashMap<u32, PendingReply>) {
let now = tokio::time::Instant::now();
let before_count = pending_replies.len();
let lapsed: Vec<(u32, tokio::time::Duration)> = pending_replies
.iter()
.map(|(id, pending)| (*id, now.duration_since(pending.sent_at)))
.filter(|(_, elapsed)| *elapsed > REPLY_TIMEOUT)
.collect();
for (packet_id, elapsed) in lapsed {
warn!("Command {} timed out after {:?}, removing from pending replies", packet_id, elapsed);
if let Some(pending) = pending_replies.remove(&packet_id) {
pending.sender.send(Err(JdwpError::ReplyTimeout(REPLY_TIMEOUT.as_secs()))).ok();
}
}
let removed = before_count - pending_replies.len();
if removed > 0 {
warn!("Cleaned up {} timed-out pending replies", removed);
}
}
fn handle_incoming_packet(
pending_replies: &mut HashMap<u32, PendingReply>,
event_tx: &mpsc::Sender<EventSet>,
result: JdwpResult<(bool, u32, Vec<u8>)>,
) -> Option<String> {
match result {
Ok((is_reply, packet_id, data)) => {
if is_reply {
route_reply(pending_replies, packet_id, &data);
None
} else {
handle_event_packet(event_tx, &data)
}
}
Err(e) => {
error!("Failed to read packet: {}", e);
Some(format!("reading from the debuggee failed: {e}"))
}
}
}
fn route_reply(pending_replies: &mut HashMap<u32, PendingReply>, packet_id: u32, data: &[u8]) {
debug!("Received reply id={}", packet_id);
if let Some(pending) = pending_replies.remove(&packet_id) {
match ReplyPacket::decode(data) {
Ok(reply) => {
pending.sender.send(Ok(reply)).ok();
}
Err(e) => {
warn!("Failed to decode reply: {}", e);
pending.sender.send(Err(e)).ok();
}
}
} else {
warn!("Received reply for unknown command id={} (may have timed out)", packet_id);
}
}
fn handle_event_packet(event_tx: &mpsc::Sender<EventSet>, data: &[u8]) -> Option<String> {
debug!("Received event packet, len={}", data.len());
let event_data = data.get(HEADER_SIZE..).unwrap_or(&[]);
match parse_event_packet(event_data) {
Ok(event_set) => {
info!(
"Parsed event set: {} events, suspend_policy={}",
event_set.events.len(),
event_set.suspend_policy
);
match event_tx.try_send(event_set) {
Ok(()) => None,
Err(mpsc::error::TrySendError::Full(dropped_event)) => {
error!("Event channel full ({} buffered), dropping event with {} events. Consumer not keeping up!",
event_tx.capacity(), dropped_event.events.len());
None
}
Err(mpsc::error::TrySendError::Closed(_)) => {
info!("Event receiver dropped, shutting down event loop");
Some("the event consumer was dropped, so the session was torn down".to_string())
}
}
}
Err(e) => {
warn!("Failed to parse event: {}", e);
None
}
}
}
const FOREIGN_BYTES_QUOTED: usize = 64;
const FOREIGN_BYTES_DEADLINE: std::time::Duration = std::time::Duration::from_millis(250);
async fn describe_foreign_bytes(reader: &mut OwnedReadHalf, header: &[u8], why: &str) -> String {
let mut seen = header.to_vec();
while seen.len() < FOREIGN_BYTES_QUOTED {
let mut chunk = [0u8; 32];
let want = (FOREIGN_BYTES_QUOTED - seen.len()).min(chunk.len());
let Some(into) = chunk.get_mut(..want) else { break };
match tokio::time::timeout(FOREIGN_BYTES_DEADLINE, reader.read(into)).await {
Ok(Ok(0) | Err(_)) | Err(_) => break,
Ok(Ok(n)) => seen.extend_from_slice(chunk.get(..n).unwrap_or_default()),
}
}
let hex = seen.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
let text: String =
seen.iter().map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }).collect();
let len_bytes = header.get(..4).unwrap_or_default();
let as_text = if len_bytes.iter().all(|&b| (0x20..0x7f).contains(&b)) {
format!(
" The length field's four bytes are the printable text {:?}, so this is text, not a size.",
String::from_utf8_lossy(len_bytes)
)
} else {
String::new()
};
format!(
"{why}.{as_text} {} byte(s) read at the header position: hex [{hex}] text \"{text}\". \
The connection cannot be resynchronised — JDWP has no frame delimiter to seek to — so the session \
ends here. If the text names a protocol or a path, something other than this JVM's JDWP agent is \
on that socket.",
seen.len()
)
}
async fn read_packet(reader: &mut OwnedReadHalf) -> JdwpResult<(bool, u32, Vec<u8>)> {
let mut header = [0u8; HEADER_SIZE];
reader.read_exact(&mut header).await.map_err(JdwpError::Io)?;
let length = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
let packet_id = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);
let flags = header[8];
let flags_are_jdwp = flags == 0 || flags == REPLY_FLAG;
let length_is_sane = (HEADER_SIZE..=MAX_PACKET_SIZE).contains(&length);
if !flags_are_jdwp || !length_is_sane {
let why = if !flags_are_jdwp {
format!("flags byte is {flags:#04x}, and JDWP defines only 0x00 (command) and 0x80 (reply)")
} else if length < HEADER_SIZE {
format!("length field is {length}, below the {HEADER_SIZE}-byte header it must include")
} else {
format!("length field is {length}, above the {MAX_PACKET_SIZE}-byte cap")
};
return Err(JdwpError::NotJdwpFramed(describe_foreign_bytes(reader, &header, &why).await));
}
let data_len = length - HEADER_SIZE;
let mut full_packet = header.to_vec();
if data_len > 0 {
let mut data = vec![0u8; data_len];
reader.read_exact(&mut data).await.map_err(JdwpError::Io)?;
full_packet.extend_from_slice(&data);
}
let is_reply = flags == REPLY_FLAG;
Ok((is_reply, packet_id, full_packet))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::CommandPacket;
struct HangingUpPeer {
handle: EventLoopHandle,
read: oneshot::Receiver<()>,
hangup: oneshot::Sender<()>,
}
async fn hanging_up_peer() -> HangingUpPeer {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind a loopback port");
let addr = listener.local_addr().expect("read back the bound address");
let (read_tx, read) = oneshot::channel();
let (hangup, mut hangup_rx) = oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept the event loop's connection");
let mut buf = vec![0u8; 1024];
let mut announced = Some(read_tx);
loop {
tokio::select! {
result = socket.read(&mut buf) => match result {
Ok(0) | Err(_) => break,
Ok(_) => if let Some(tx) = announced.take() { let _ = tx.send(()); },
},
_ = &mut hangup_rx => break,
}
}
drop(socket);
});
let stream = tokio::net::TcpStream::connect(addr).await.expect("connect to the peer");
let (reader, writer) = stream.into_split();
HangingUpPeer { handle: spawn_event_loop(reader, writer), read, hangup }
}
#[tokio::test]
async fn a_command_in_flight_when_the_debuggee_hangs_up_is_told_why() {
let peer = hanging_up_peer().await;
let handle = peer.handle.clone();
let in_flight = tokio::spawn(async move { handle.send_command(CommandPacket::new(1, 1, 1)).await });
peer.read.await.expect("the peer should have read the command");
peer.hangup.send(()).expect("the peer should still be listening for the hang-up");
let err = in_flight.await.expect("the command task should not panic").expect_err(
"a command whose connection died cannot succeed — the peer never sent a reply packet",
);
let JdwpError::ConnectionClosed(cause) = &err else {
panic!("expected ConnectionClosed carrying the reason, got {err:?}");
};
assert!(
cause.contains("reading from the debuggee failed"),
"the cause must name what happened to the connection, not just that it ended: {cause}"
);
}
async fn garbage_peer(bytes: &'static [u8]) -> EventLoopHandle {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind a loopback port");
let addr = listener.local_addr().expect("read back the bound address");
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept the event loop's connection");
let _ = socket.write_all(bytes).await;
let _ = socket.flush().await;
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
});
let stream = tokio::net::TcpStream::connect(addr).await.expect("connect to the peer");
let (reader, writer) = stream.into_split();
spawn_event_loop(reader, writer)
}
#[tokio::test]
async fn a_length_field_that_is_really_text_says_so_instead_of_claiming_a_huge_packet() {
let handle = garbage_peer(b"ent/management/RuntimeMXBean;junk padding to fill the quote").await;
let err = handle
.send_command(CommandPacket::new(1, 1, 1))
.await
.expect_err("a stream that is not JDWP-framed cannot answer a command");
let JdwpError::ConnectionClosed(cause) = &err else {
panic!("expected ConnectionClosed carrying the reason, got {err:?}");
};
assert!(
cause.contains("not JDWP-framed"),
"the failure must name the framing, which is what is actually wrong: {cause}"
);
assert!(
cause.contains("printable text") && cause.contains("ent/"),
"the decoded length field is the whole insight — 1701737519 tells nobody anything: {cause}"
);
assert!(
!cause.contains("Packet too large"),
"reporting this as a large packet is the misdiagnosis being fixed: {cause}"
);
assert!(cause.contains("hex ["), "the raw bytes must be quoted: {cause}");
assert!(
cause.contains("management/"),
"quoting only 4 bytes is what made the first sighting ambiguous; the run has to be long \
enough to recognise: {cause}"
);
}
#[tokio::test]
async fn a_flags_byte_jdwp_never_uses_is_caught_even_when_the_length_looks_plausible() {
let bytes: &'static [u8] = b"\x00\x00\x00\x20\x00\x00\x00\x01AGET / HTTP/1.1\r\nHost: x\r\n\r\n";
let handle = garbage_peer(bytes).await;
let err = handle
.send_command(CommandPacket::new(1, 1, 1))
.await
.expect_err("a stream whose flags byte is not JDWP cannot answer a command");
let JdwpError::ConnectionClosed(cause) = &err else {
panic!("expected ConnectionClosed carrying the reason, got {err:?}");
};
assert!(
cause.contains("flags byte is 0x41"),
"the flags value is the finding here, and it must be named: {cause}"
);
assert!(
cause.contains("0x00 (command)") && cause.contains("0x80 (reply)"),
"say what JDWP does allow, so the reader can tell foreign traffic from a version skew: {cause}"
);
}
async fn chunked_reply_peer(payload: Vec<u8>) -> (EventLoopHandle, oneshot::Sender<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind a loopback port");
let addr = listener.local_addr().expect("read back the bound address");
let (finish, finish_rx) = oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept the event loop's connection");
let mut cmd = [0u8; HEADER_SIZE];
socket.read_exact(&mut cmd).await.expect("read the first command's header");
let id = u32::from_be_bytes([cmd[4], cmd[5], cmd[6], cmd[7]]);
let total = u32::try_from(HEADER_SIZE + payload.len()).expect("reply fits in u32");
let mut head = Vec::new();
head.extend_from_slice(&total.to_be_bytes());
head.extend_from_slice(&id.to_be_bytes());
head.push(REPLY_FLAG);
head.extend_from_slice(&0u16.to_be_bytes());
head.extend_from_slice(&payload[..1]);
socket.write_all(&head).await.expect("write the first chunk");
socket.flush().await.expect("flush the first chunk");
let _ = finish_rx.await;
socket.write_all(&payload[1..]).await.expect("write the second chunk");
socket.flush().await.expect("flush the second chunk");
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
});
let stream = tokio::net::TcpStream::connect(addr).await.expect("connect to the peer");
let (reader, writer) = stream.into_split();
(spawn_event_loop(reader, writer), finish)
}
#[tokio::test]
async fn a_command_sent_mid_packet_does_not_desynchronise_the_stream() {
let payload: Vec<u8> = (0..8192u32).map(|i| u8::try_from(i % 251).unwrap_or(0)).collect();
let (handle, finish) = chunked_reply_peer(payload.clone()).await;
let first = tokio::spawn({
let h = handle.clone();
async move { h.send_command(CommandPacket::new(1, 1, 1)).await }
});
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let second = tokio::spawn({
let h = handle.clone();
async move { h.send_command(CommandPacket::new(2, 1, 1)).await }
});
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
finish.send(()).expect("the peer should still be waiting to send the rest");
let reply = first
.await
.expect("the first command's task should not panic")
.expect("the first reply must arrive intact — a cancelled read would have desynchronised here");
assert_eq!(
reply.data(),
&payload[..],
"the reply payload must be byte-identical; a short or shifted payload is the desync this test exists for"
);
second.abort();
}
#[tokio::test]
async fn a_question_asked_after_the_debuggee_hung_up_still_names_the_cause() {
let peer = hanging_up_peer().await;
let handle = peer.handle.clone();
let in_flight = tokio::spawn(async move { handle.send_command(CommandPacket::new(1, 1, 1)).await });
peer.read.await.expect("the peer should have read the command");
peer.hangup.send(()).expect("the peer should still be listening for the hang-up");
let _ = in_flight.await.expect("the command task should not panic");
let err = peer
.handle
.send_command(CommandPacket::new(2, 1, 1))
.await
.expect_err("the connection is gone; nothing can answer this");
let JdwpError::ConnectionClosed(cause) = &err else {
panic!("expected ConnectionClosed carrying the reason, got {err:?}");
};
assert!(
cause.contains("reading from the debuggee failed"),
"a later caller must get the same diagnosis as the first: {cause}"
);
}
#[tokio::test(start_paused = true)]
async fn a_reply_that_never_arrives_is_reported_as_a_lapsed_reply_not_a_dead_connection() {
let mut pending = HashMap::new();
let (sender, receiver) = oneshot::channel();
pending.insert(7, PendingReply { sender, sent_at: tokio::time::Instant::now() });
tokio::time::advance(REPLY_TIMEOUT / 2).await;
cleanup_pending_replies(&mut pending);
assert_eq!(pending.len(), 1, "abandoned a command that still had time left");
tokio::time::advance(REPLY_TIMEOUT).await;
cleanup_pending_replies(&mut pending);
assert!(pending.is_empty(), "a lapsed command must be dropped from the pending map");
let err = receiver
.await
.expect("the lapsed command must be told, not left to a dropped sender")
.expect_err("a lapsed reply is not a success");
assert!(
matches!(err, JdwpError::ReplyTimeout(secs) if secs == REPLY_TIMEOUT.as_secs()),
"expected ReplyTimeout naming the budget, got {err:?}"
);
}
}