Skip to content

Commit 6e964c6

Browse files
authored
Merge pull request #5 from runtimed/comms-testing
Implement comms_lifecycle test
2 parents b60bf1a + 1cbd6be commit 6e964c6

2 files changed

Lines changed: 147 additions & 7 deletions

File tree

src/harness.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ use crate::types::{KernelReport, TestCategory, TestRecord, TestResult};
55
use chrono::Utc;
66
use jupyter_protocol::connection_info::{ConnectionInfo, Transport};
77
use jupyter_protocol::messaging::{
8-
ExecuteRequest, ExecutionState, InputReply, JupyterMessage, JupyterMessageContent,
9-
KernelInfoReply, KernelInfoRequest, ReplyStatus, ShutdownRequest, Status,
8+
CommClose, CommOpen, ExecuteRequest, ExecutionState, InputReply, JupyterMessage,
9+
JupyterMessageContent, KernelInfoReply, KernelInfoRequest, ReplyStatus, ShutdownRequest,
10+
Status,
1011
};
1112
use runtimelib::{
1213
create_client_control_connection, create_client_heartbeat_connection,
@@ -224,6 +225,60 @@ impl KernelUnderTest {
224225
.map_err(|e| HarnessError::ProtocolError(e.to_string()))
225226
}
226227

228+
/// Send a request on shell and wait for reply, also collecting IOPub messages.
229+
pub async fn shell_request_with_iopub(
230+
&mut self,
231+
content: impl Into<JupyterMessageContent>,
232+
) -> Result<(JupyterMessage, Vec<JupyterMessage>)> {
233+
let request: JupyterMessage = JupyterMessage::new(content, None);
234+
let msg_id = request.header.msg_id.clone();
235+
236+
self.shell
237+
.send(request)
238+
.await
239+
.map_err(|e| HarnessError::ProtocolError(e.to_string()))?;
240+
241+
// Collect IOPub messages until idle
242+
let mut iopub_messages = Vec::new();
243+
let start = Instant::now();
244+
245+
loop {
246+
if start.elapsed() > self.test_timeout {
247+
return Err(HarnessError::Timeout("iopub idle".to_string()));
248+
}
249+
250+
match timeout(Duration::from_millis(100), self.iopub.read()).await {
251+
Ok(Ok(msg)) => {
252+
if msg.parent_header.as_ref().map(|h| &h.msg_id) == Some(&msg_id) {
253+
let is_idle = matches!(
254+
&msg.content,
255+
JupyterMessageContent::Status(Status { execution_state })
256+
if *execution_state == ExecutionState::Idle
257+
);
258+
iopub_messages.push(msg);
259+
if is_idle {
260+
break;
261+
}
262+
}
263+
}
264+
Ok(Err(e)) => {
265+
return Err(HarnessError::ProtocolError(e.to_string()));
266+
}
267+
Err(_) => {
268+
// Timeout on this read, continue
269+
}
270+
}
271+
}
272+
273+
// Read shell reply
274+
let reply = timeout(self.test_timeout, self.shell.read())
275+
.await
276+
.map_err(|_| HarnessError::Timeout("shell reply".to_string()))?
277+
.map_err(|e| HarnessError::ProtocolError(e.to_string()))?;
278+
279+
Ok((reply, iopub_messages))
280+
}
281+
227282
/// Send a request on control and wait for reply.
228283
pub async fn control_request(
229284
&mut self,
@@ -398,6 +453,47 @@ impl KernelUnderTest {
398453
&mut self.stdin
399454
}
400455

456+
/// Send comm_open and check if kernel rejects it (returns true if rejected).
457+
pub async fn send_comm_open(&mut self, msg: CommOpen) -> Result<bool> {
458+
let comm_id = msg.comm_id.clone();
459+
let request: JupyterMessage = JupyterMessage::new(msg, None);
460+
461+
self.shell
462+
.send(request)
463+
.await
464+
.map_err(|e| HarnessError::ProtocolError(e.to_string()))?;
465+
466+
// Brief wait for potential comm_close rejection on IOPub
467+
let start = Instant::now();
468+
while start.elapsed() < Duration::from_millis(500) {
469+
match timeout(Duration::from_millis(100), self.iopub.read()).await {
470+
Ok(Ok(msg)) => {
471+
if let JupyterMessageContent::CommClose(close) = &msg.content {
472+
if close.comm_id == comm_id {
473+
return Ok(true); // Rejected
474+
}
475+
}
476+
}
477+
_ => {}
478+
}
479+
}
480+
481+
Ok(false) // Not rejected (accepted or ignored)
482+
}
483+
484+
/// Send comm_close to clean up a comm.
485+
pub async fn send_comm_close(&mut self, msg: CommClose) -> Result<()> {
486+
let request: JupyterMessage = JupyterMessage::new(msg, None);
487+
self.shell
488+
.send(request)
489+
.await
490+
.map_err(|e| HarnessError::ProtocolError(e.to_string()))?;
491+
492+
// Brief wait for processing
493+
tokio::time::sleep(Duration::from_millis(100)).await;
494+
Ok(())
495+
}
496+
401497
/// Shutdown the kernel cleanly.
402498
pub async fn shutdown(mut self) -> Result<()> {
403499
let request = ShutdownRequest { restart: false };

src/tests.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
use crate::harness::{ConformanceTest, KernelUnderTest};
44
use crate::types::{FailureKind, TestCategory, TestResult};
55
use jupyter_protocol::messaging::{
6-
CommInfoRequest, CompleteRequest, ExecutionState, HistoryRequest, InspectRequest,
7-
InterruptRequest, IsCompleteReplyStatus, IsCompleteRequest, JupyterMessageContent, ReplyStatus,
8-
ShutdownRequest, Status, StreamContent,
6+
CommClose, CommId, CommInfoRequest, CommOpen, CompleteRequest, ExecutionState, HistoryRequest,
7+
InspectRequest, InterruptRequest, IsCompleteReplyStatus, IsCompleteRequest,
8+
JupyterMessageContent, ReplyStatus, ShutdownRequest, Status, StreamContent,
99
};
1010
use std::future::Future;
1111
use std::pin::Pin;
@@ -618,9 +618,53 @@ fn test_stdin_input_request(
618618
}
619619

620620
fn test_comms_lifecycle(
621-
_kernel: &mut KernelUnderTest,
621+
kernel: &mut KernelUnderTest,
622622
) -> Pin<Box<dyn Future<Output = TestResult> + Send + '_>> {
623-
Box::pin(async move { TestResult::Unsupported })
623+
Box::pin(async move {
624+
// Generate a unique comm_id for this test
625+
let comm_id = CommId(format!("test-comm-{}", uuid::Uuid::new_v4()));
626+
627+
// Open a comm with a test target
628+
// Most kernels won't have this registered, but should handle it gracefully
629+
let open_msg = CommOpen {
630+
comm_id: comm_id.clone(),
631+
target_name: "jupyter.kernel_testbed.test".to_string(),
632+
data: serde_json::Map::new(),
633+
target_module: None,
634+
};
635+
636+
// Send comm_open on shell channel
637+
// Note: comm_open doesn't get a direct reply, but we can verify the kernel
638+
// handles it by checking IOPub for comm_close (rejection) or doing a
639+
// follow-up execute to ensure kernel is still responsive
640+
match kernel.send_comm_open(open_msg).await {
641+
Ok(rejection) => {
642+
if rejection {
643+
// Kernel properly rejected unknown target with comm_close
644+
TestResult::Pass
645+
} else {
646+
// Kernel accepted (or silently ignored) the comm_open
647+
// Send comm_close to clean up, then verify kernel still works
648+
let close_msg = CommClose {
649+
comm_id: comm_id.clone(),
650+
data: serde_json::Map::new(),
651+
};
652+
let _ = kernel.send_comm_close(close_msg).await;
653+
654+
// Verify kernel is still responsive
655+
let code = kernel.snippets().complete_code.to_string();
656+
match kernel.execute_and_collect(&code).await {
657+
Ok(_) => TestResult::Pass,
658+
Err(e) => TestResult::fail(
659+
format!("Kernel unresponsive after comm: {}", e),
660+
FailureKind::HarnessError,
661+
),
662+
}
663+
}
664+
}
665+
Err(e) => TestResult::fail(e.to_string(), FailureKind::HarnessError),
666+
}
667+
})
624668
}
625669

626670
fn test_interrupt_request(

0 commit comments

Comments
 (0)