Skip to content

Commit 1ccff88

Browse files
authored
Merge pull request #54 from posit-dev/feature/interrupt-tests
Add tests for kernel interrupt
2 parents 97c7feb + 1506a14 commit 1ccff88

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

crates/kcserver/tests/common/test_utils.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ async fn find_python_executable() -> Option<String> {
250250
vec!["python3", "python"]
251251
};
252252

253+
// First try standard Python commands in PATH
253254
for candidate in candidates {
254255
match tokio::process::Command::new(candidate)
255256
.arg("--version")
@@ -296,6 +297,52 @@ async fn find_python_executable() -> Option<String> {
296297
_ => continue,
297298
}
298299
}
300+
301+
// On Windows, if not found in PATH, try common installation locations
302+
#[cfg(windows)]
303+
{
304+
println!("Python not found in PATH, checking common Windows installation locations...");
305+
306+
let home_dir = std::env::var("USERPROFILE").unwrap_or_default();
307+
if !home_dir.is_empty() {
308+
let mut potential_paths = vec![
309+
// pyenv-win
310+
format!("{}\\.pyenv\\pyenv-win\\shims\\python.bat", home_dir),
311+
];
312+
313+
// Standard Python.org installations for multiple versions
314+
for version in &["313", "312", "311", "310", "39", "38"] {
315+
potential_paths.push(format!(
316+
"{}\\AppData\\Local\\Programs\\Python\\Python{}\\python.exe",
317+
home_dir, version
318+
));
319+
}
320+
321+
// Also check C:\Python directories (sometimes used in CI environments)
322+
for version in &["313", "312", "311", "310", "39", "38"] {
323+
potential_paths.push(format!("C:\\Python{}\\python.exe", version));
324+
}
325+
326+
// Check Microsoft Store installations
327+
potential_paths.push(format!(
328+
"{}\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe",
329+
home_dir
330+
));
331+
332+
for path in potential_paths {
333+
if std::path::Path::new(&path).exists() {
334+
println!("Checking Python installation at: {}", path);
335+
if check_ipykernel_available(&path).await {
336+
println!("Found Python with ipykernel at: {}", path);
337+
return Some(path);
338+
} else {
339+
println!("Python at {} does not have ipykernel - skipping", path);
340+
}
341+
}
342+
}
343+
}
344+
}
345+
299346
None
300347
}
301348

crates/kcserver/tests/python_kernel_tests.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,3 +1829,272 @@ except Exception as e:
18291829
}
18301830
}
18311831
}
1832+
1833+
#[tokio::test]
1834+
async fn test_kernel_interrupt() {
1835+
let test_result = tokio::time::timeout(Duration::from_secs(30), async {
1836+
let python_cmd = if let Some(cmd) = get_python_executable().await {
1837+
cmd
1838+
} else {
1839+
println!("Skipping test: No Python executable found");
1840+
return;
1841+
};
1842+
1843+
if !is_ipykernel_available().await {
1844+
println!("Skipping test: ipykernel not available for {}", python_cmd);
1845+
return;
1846+
}
1847+
1848+
let server = TestServer::start().await;
1849+
let client = server.create_client().await;
1850+
1851+
let session_id = format!("interrupt-test-session-{}", Uuid::new_v4());
1852+
1853+
// Create a session with Signal interrupt mode for Windows
1854+
#[cfg_attr(not(windows), allow(unused_mut))]
1855+
let mut new_session = create_test_session(session_id.clone(), &python_cmd);
1856+
#[cfg(windows)]
1857+
{
1858+
new_session.interrupt_mode = InterruptMode::Signal;
1859+
}
1860+
1861+
// Create and start the kernel session
1862+
let _created_session_id = create_session_with_client(&client, new_session).await;
1863+
1864+
println!("Starting kernel session for interrupt test...");
1865+
let start_response = client
1866+
.start_session(session_id.clone())
1867+
.await
1868+
.expect("Failed to start session");
1869+
1870+
println!("Start response: {:?}", start_response);
1871+
1872+
// Check if the session started successfully
1873+
match &start_response {
1874+
kallichore_api::StartSessionResponse::Started(_) => {
1875+
println!("Kernel started successfully");
1876+
}
1877+
kallichore_api::StartSessionResponse::StartFailed(error) => {
1878+
println!("Kernel failed to start: {:?}", error);
1879+
println!("Skipping interrupt test due to startup failure");
1880+
return;
1881+
}
1882+
_ => {
1883+
println!("Unexpected start response: {:?}", start_response);
1884+
println!("Skipping interrupt test");
1885+
return;
1886+
}
1887+
}
1888+
1889+
// Wait for kernel to fully start
1890+
tokio::time::sleep(Duration::from_millis(1500)).await;
1891+
1892+
// Create a websocket connection
1893+
let ws_url = format!(
1894+
"ws://localhost:{}/sessions/{}/channels",
1895+
server.port(),
1896+
session_id
1897+
);
1898+
1899+
let mut comm = CommunicationChannel::create_websocket(&ws_url)
1900+
.await
1901+
.expect("Failed to create websocket");
1902+
1903+
// Wait for websocket to be ready
1904+
tokio::time::sleep(Duration::from_millis(500)).await;
1905+
1906+
// Execute code that will take a long time (loop that prints numbers)
1907+
let long_running_code = r#"
1908+
import time
1909+
for i in range(100):
1910+
print(f"Iteration {i}")
1911+
time.sleep(0.1)
1912+
print("All iterations completed!")
1913+
"#;
1914+
1915+
let execute_request = WebsocketMessage::Jupyter(JupyterMessage {
1916+
header: JupyterMessageHeader {
1917+
msg_id: Uuid::new_v4().to_string(),
1918+
msg_type: "execute_request".to_string(),
1919+
},
1920+
parent_header: None,
1921+
channel: JupyterChannel::Shell,
1922+
content: serde_json::json!({
1923+
"code": long_running_code,
1924+
"silent": false,
1925+
"store_history": false,
1926+
"user_expressions": {},
1927+
"allow_stdin": false,
1928+
"stop_on_error": true
1929+
}),
1930+
metadata: serde_json::json!({}),
1931+
buffers: vec![],
1932+
});
1933+
1934+
println!("Sending long-running execute_request...");
1935+
comm.send_message(&execute_request)
1936+
.await
1937+
.expect("Failed to send execute_request");
1938+
1939+
// Wait for some iterations to be printed
1940+
println!("Waiting for some iterations to be printed...");
1941+
let mut iterations_printed = Vec::new();
1942+
let start_time = std::time::Instant::now();
1943+
1944+
// Collect some output for 1 second
1945+
while start_time.elapsed() < Duration::from_secs(1) {
1946+
match tokio::time::timeout(Duration::from_millis(100), comm.receive_message()).await {
1947+
Ok(Ok(Some(message_text))) => {
1948+
if let Ok(WebsocketMessage::Jupyter(jupyter_msg)) =
1949+
serde_json::from_str::<WebsocketMessage>(&message_text)
1950+
{
1951+
if jupyter_msg.header.msg_type == "stream" {
1952+
if let Some(text) =
1953+
jupyter_msg.content.get("text").and_then(|v| v.as_str())
1954+
{
1955+
println!("Received output: {}", text);
1956+
// Extract iteration numbers
1957+
for line in text.lines() {
1958+
if let Some(num_str) = line.strip_prefix("Iteration ") {
1959+
if let Ok(num) = num_str.parse::<i32>() {
1960+
iterations_printed.push(num);
1961+
}
1962+
}
1963+
}
1964+
}
1965+
}
1966+
}
1967+
}
1968+
_ => {}
1969+
}
1970+
}
1971+
1972+
println!(
1973+
"Collected {} iterations before interrupt: {:?}",
1974+
iterations_printed.len(),
1975+
iterations_printed
1976+
);
1977+
1978+
// Verify we got some iterations (at least 5, should be around 10)
1979+
assert!(
1980+
iterations_printed.len() >= 5,
1981+
"Expected at least 5 iterations before interrupt, got {}",
1982+
iterations_printed.len()
1983+
);
1984+
1985+
// Now interrupt the kernel using the interrupt endpoint
1986+
println!("Sending interrupt request via HTTP API...");
1987+
let interrupt_response = client
1988+
.interrupt_session(session_id.clone())
1989+
.await
1990+
.expect("Failed to send interrupt");
1991+
1992+
println!("Interrupt response: {:?}", interrupt_response);
1993+
1994+
// Verify the interrupt was accepted
1995+
match interrupt_response {
1996+
kallichore_api::InterruptSessionResponse::Interrupted(_) => {
1997+
println!("Interrupt request was accepted");
1998+
}
1999+
_ => {
2000+
panic!(
2001+
"Expected interrupt to succeed, got: {:?}",
2002+
interrupt_response
2003+
);
2004+
}
2005+
}
2006+
2007+
// Now collect more output and verify that:
2008+
// 1. We receive an error or execute_reply indicating interruption
2009+
// 2. We did NOT receive "All iterations completed!"
2010+
println!("Collecting output after interrupt...");
2011+
let mut all_output = String::new();
2012+
let mut execute_reply_received = false;
2013+
let start_time = std::time::Instant::now();
2014+
2015+
while start_time.elapsed() < Duration::from_secs(5) {
2016+
match tokio::time::timeout(Duration::from_millis(200), comm.receive_message()).await {
2017+
Ok(Ok(Some(message_text))) => {
2018+
if let Ok(WebsocketMessage::Jupyter(jupyter_msg)) =
2019+
serde_json::from_str::<WebsocketMessage>(&message_text)
2020+
{
2021+
println!("Received message type: {}", jupyter_msg.header.msg_type);
2022+
2023+
if jupyter_msg.header.msg_type == "stream" {
2024+
if let Some(text) =
2025+
jupyter_msg.content.get("text").and_then(|v| v.as_str())
2026+
{
2027+
all_output.push_str(text);
2028+
println!("Stream output: {}", text);
2029+
}
2030+
} else if jupyter_msg.header.msg_type == "error" {
2031+
println!("Received error message: {:?}", jupyter_msg.content);
2032+
all_output.push_str("ERROR_RECEIVED\n");
2033+
} else if jupyter_msg.header.msg_type == "execute_reply" {
2034+
println!("Received execute_reply: {:?}", jupyter_msg.content);
2035+
if let Some(status) =
2036+
jupyter_msg.content.get("status").and_then(|v| v.as_str())
2037+
{
2038+
println!("Execute reply status: {}", status);
2039+
if status == "error" {
2040+
all_output.push_str("EXECUTE_ERROR\n");
2041+
}
2042+
}
2043+
execute_reply_received = true;
2044+
break;
2045+
}
2046+
}
2047+
}
2048+
_ => {}
2049+
}
2050+
}
2051+
2052+
println!("All output after interrupt:\n{}", all_output);
2053+
2054+
// Verify we got an execute_reply
2055+
assert!(
2056+
execute_reply_received,
2057+
"Expected to receive execute_reply after interrupt"
2058+
);
2059+
2060+
// Verify that the code did NOT complete all iterations
2061+
assert!(
2062+
!all_output.contains("All iterations completed!"),
2063+
"Code should have been interrupted before completion"
2064+
);
2065+
2066+
// Verify that we got SOME output (the interrupt didn't happen immediately)
2067+
assert!(
2068+
iterations_printed.len() > 0,
2069+
"Expected some iterations to complete before interrupt"
2070+
);
2071+
2072+
// Verify that we did NOT get all 100 iterations
2073+
assert!(
2074+
iterations_printed.len() < 100,
2075+
"Expected interrupt to prevent all iterations from completing"
2076+
);
2077+
2078+
println!(
2079+
"✓ Interrupt test passed! Code was interrupted after {} iterations (out of 100)",
2080+
iterations_printed.len()
2081+
);
2082+
2083+
// Clean up
2084+
if let Err(e) = comm.close().await {
2085+
println!("Failed to close communication channel: {}", e);
2086+
}
2087+
2088+
drop(server);
2089+
})
2090+
.await;
2091+
2092+
match test_result {
2093+
Ok(_) => {
2094+
println!("Kernel interrupt test completed successfully");
2095+
}
2096+
Err(_) => {
2097+
panic!("Kernel interrupt test timed out after 30 seconds");
2098+
}
2099+
}
2100+
}

0 commit comments

Comments
 (0)